Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e36ff13a8 | ||
|
|
53ef35968e | ||
|
|
ab128515ab | ||
|
|
296b538d20 | ||
|
|
7539680ae0 | ||
|
|
005b4786c0 | ||
|
|
a2e1723e33 | ||
|
|
dd7e42caf4 | ||
|
|
fe8870af71 | ||
|
|
27dbe3bccb | ||
|
|
090a7afdd7 | ||
|
|
042b1e5046 | ||
|
|
72e5d50cd0 | ||
|
|
fd075a7f80 | ||
|
|
78f1a2cfc4 | ||
|
|
2aadae339e | ||
|
|
3eb4f5669b | ||
|
|
865d33a0e1 | ||
|
|
1d81ca34e1 | ||
|
|
8374254020 | ||
|
|
ae913f7ad6 | ||
|
|
68296d8fe0 | ||
|
|
949b0b52d6 | ||
|
|
6a8c3a27e2 | ||
|
|
75532d8d8e | ||
|
|
10b2b4a8e0 | ||
|
|
17f1c5d46f | ||
|
|
4ef480d1f8 | ||
|
|
df16b40a9d | ||
|
|
fbd565651a | ||
|
|
fe8f6a871b | ||
|
|
c9f97e2f4c | ||
|
|
4b8c776357 |
@@ -28,16 +28,16 @@
|
|||||||
1. Не трогать рабочий код без явного указания.
|
1. Не трогать рабочий код без явного указания.
|
||||||
|
|
||||||
2. Файлы редактируются локально:
|
2. Файлы редактируются локально:
|
||||||
~/fission
|
~/fission-console
|
||||||
|
|
||||||
После ЛЮБЫХ изменений ОБЯЗАТЕЛЬНО синхронизировать на ВМ командой:
|
После ЛЮБЫХ изменений ОБЯЗАТЕЛЬНО синхронизировать на ВМ командой:
|
||||||
rsync -az \
|
rsync -az \
|
||||||
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
|
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
|
||||||
~/fission/ \
|
~/fission-console/ \
|
||||||
naeel@5.172.178.213:~/terra/fission/
|
naeel@5.172.178.213:~/terra/fission/
|
||||||
|
|
||||||
|
|
||||||
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission
|
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission-console
|
||||||
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ:
|
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ:
|
||||||
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
|
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -13,14 +13,14 @@
|
|||||||
|
|
||||||
## Файловая система (актуально)
|
## Файловая система (актуально)
|
||||||
|
|
||||||
1. Все файлы редактируются локально: `~/fission`
|
1. Все файлы редактируются локально: `~/fission-console`
|
||||||
2. После любых изменений — обязательно rsync на ВМ:
|
2. После любых изменений — обязательно rsync на ВМ:
|
||||||
rsync -az \
|
rsync -az \
|
||||||
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
|
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
|
||||||
~/fission/ \
|
~/fission-console/ \
|
||||||
naeel@5.172.178.213:~/terra/fission/
|
naeel@5.172.178.213:~/terra/fission/
|
||||||
|
|
||||||
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission
|
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission-console
|
||||||
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ
|
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ
|
||||||
5. Перед запуском любой команды на ВМ обязательно убедиться, что синхронизация (rsync) выполнена
|
5. Перед запуском любой команды на ВМ обязательно убедиться, что синхронизация (rsync) выполнена
|
||||||
6. SCP, sshfs, remote_dev и маунты больше НЕ используются
|
6. SCP, sshfs, remote_dev и маунты больше НЕ используются
|
||||||
@@ -50,7 +50,7 @@ ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=
|
|||||||
## Git
|
## Git
|
||||||
|
|
||||||
⛔⛔⛔ АБСОЛЮТНОЕ ПРАВИЛО:
|
⛔⛔⛔ АБСОЛЮТНОЕ ПРАВИЛО:
|
||||||
- Git — ТОЛЬКО ЛОКАЛЬНО в `~/fission`. НИКОГДА через SSH на VM.
|
- Git — ТОЛЬКО ЛОКАЛЬНО в `~/fission-console`. НИКОГДА через SSH на VM.
|
||||||
- Разрешены ТОЛЬКО две операции: `git commit` и `git push`.
|
- Разрешены ТОЛЬКО две операции: `git commit` и `git push`.
|
||||||
- ЗАПРЕЩЕНО: git pull, git fetch, git rebase, git merge, git reset, git stash, git checkout — что угодно кроме commit и push.
|
- ЗАПРЕЩЕНО: git pull, git fetch, git rebase, git merge, git reset, git stash, git checkout — что угодно кроме commit и push.
|
||||||
- Если push отклонён — СТОП, доложить пользователю. Не лезть в pull/merge/rebase самостоятельно.
|
- Если push отклонён — СТОП, доложить пользователю. Не лезть в pull/merge/rebase самостоятельно.
|
||||||
@@ -86,7 +86,7 @@ LOG="test-results/$(date +%Y-%m-%d_%H-%M).log"
|
|||||||
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 \
|
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 \
|
||||||
"bash ~/terra/fission/scripts/run_all.sh 2>&1 | tee ~/terra/fission/${LOG}"
|
"bash ~/terra/fission/scripts/run_all.sh 2>&1 | tee ~/terra/fission/${LOG}"
|
||||||
rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no" \
|
rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no" \
|
||||||
naeel@5.172.178.213:~/terra/fission/test-results/ ~/fission/test-results/
|
naeel@5.172.178.213:~/terra/fission/test-results/ ~/fission-console/test-results/
|
||||||
```
|
```
|
||||||
|
|
||||||
**Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.**
|
**Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Образы нашего Fission — актуальные теги
|
||||||
|
|
||||||
|
> Обновлено: 2026-05-21
|
||||||
|
|
||||||
|
## Текущие образы (production)
|
||||||
|
|
||||||
|
| Компонент | Docker-образ | Тег |
|
||||||
|
|---|---|---|
|
||||||
|
| **fission-bundle** (storagesvc, router, executor, buildermgr, kubewatcher, mqtrigger, timer, webhook) | `naeel/fission-bundle` | `v1.23.1` |
|
||||||
|
| **fission-console** (UI) | `naeel/fission-console` | `v1.3.96` |
|
||||||
|
| **Python environment** | `naeel/fission-python-env` | `v1.1` |
|
||||||
|
| **metrics-collector** | `naeel/metrics-collector` | `v0.1.3` |
|
||||||
|
|
||||||
|
## Storagesvc backend
|
||||||
|
|
||||||
|
**Текущий (2026-05-20): S3** — ngcloud msk-1, бакет `sless-functions`.
|
||||||
|
PV-конфиг сохранён в `helm/fission-values-pv.yaml` для быстрого возврата.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Где в коде консоли прописаны образы
|
||||||
|
|
||||||
|
### Python environment
|
||||||
|
Файл: `console/internal/model/types.go`
|
||||||
|
```go
|
||||||
|
LangEnvMap["python"] = EnvConfig{Image: "naeel/fission-python-env:v1.1", ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Как пересобрать fission-bundle (после правки в fission-src)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Внести правки в ~/fission-src/...
|
||||||
|
|
||||||
|
# 2. rsync на ВМ
|
||||||
|
rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
|
||||||
|
~/fission-src/ naeel@5.172.178.213:~/terra/fission-src/
|
||||||
|
|
||||||
|
# 3. На ВМ — собрать бинарник
|
||||||
|
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 '
|
||||||
|
cd ~/terra/fission-src &&
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o _build/linux_amd64/fission-bundle ./cmd/fission-bundle/ &&
|
||||||
|
echo OK'
|
||||||
|
|
||||||
|
# 4. На ВМ — собрать и запушить образ (инкрементировать тег!)
|
||||||
|
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 '
|
||||||
|
cd ~/terra/fission-src/_build/linux_amd64 &&
|
||||||
|
docker build -t naeel/fission-bundle:vX.Y.Z . &&
|
||||||
|
docker push naeel/fission-bundle:vX.Y.Z'
|
||||||
|
|
||||||
|
# 5. Задеплоить только тот сервис, который менялся
|
||||||
|
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 '
|
||||||
|
kubectl set image -n fission deploy/storagesvc storagesvc=naeel/fission-bundle:vX.Y.Z &&
|
||||||
|
kubectl rollout status -n fission deploy/storagesvc'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Сервисы и имена контейнеров (для kubectl set image)
|
||||||
|
|
||||||
|
| Deployment | Container name |
|
||||||
|
|---|---|
|
||||||
|
| `deploy/storagesvc` | `storagesvc` |
|
||||||
|
| `deploy/router` | `router` |
|
||||||
|
| `deploy/executor` | `executor` |
|
||||||
|
| `deploy/buildermgr` | `buildermgr` |
|
||||||
|
| `deploy/kubewatcher` | `kubewatcher` |
|
||||||
|
| `deploy/timer` | `timer` |
|
||||||
|
| `deploy/webhook` | `webhook` |
|
||||||
|
| `deploy/mqtrigger-keda` | `mqtrigger-keda` |
|
||||||
|
| `deploy/fission-console` | `fission-console` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Как пересобрать fission-console (после правки в fission-console)
|
||||||
|
|
||||||
|
Подробный порядок — в `CONTRIBUTING.md` консоли.
|
||||||
|
Кратко:
|
||||||
|
1. Увеличить тег в `console/deploy/console.yaml` (vX.Y.Z → vX.Y.Z+1)
|
||||||
|
2. Обновить версию в `console/ui/index.html` (2 места)
|
||||||
|
3. rsync на ВМ
|
||||||
|
4. Проверить файлы на ВМ (grep)
|
||||||
|
5. `docker build -t naeel/fission-console:vX.Y.Z+1`
|
||||||
|
6. `docker push naeel/fission-console:vX.Y.Z+1`
|
||||||
|
7. `kubectl apply -f ~/terra/fission/console/deploy/console.yaml` — **не** rollout restart!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ ВАЖНО
|
||||||
|
|
||||||
|
- **НИКОГДА** не использовать официальный `ghcr.io/fission/python-env` — не поддерживает `def main(event, context)` → 500
|
||||||
|
- Наш образ `naeel/fission-python-env:v1.1` содержит патч `inspect.signature`
|
||||||
|
- imagePullPolicy: `IfNotPresent` → при смене образа **всегда** менять тег, не переиспользовать старый
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Fix: 401 "key is of invalid type" при invoke функций
|
||||||
|
|
||||||
|
## Статус: ГОТОВО К ПРАВКЕ (не исправлено)
|
||||||
|
|
||||||
|
## Root Cause (установлен 2026-05-18)
|
||||||
|
|
||||||
|
Secret `router` в k8s namespace `fission` был ротирован.
|
||||||
|
Router pod перезапустился — подхватил новый пароль.
|
||||||
|
Console pod НЕ перезапускался (5d8h) — имеет старый пароль в env var.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `FISSION_AUTH_PASSWORD` в console pod (env) | `ToAjBTs0Sb8mLC1J9NN3` (СТАРЫЙ) |
|
||||||
|
| `password` в k8s secret `router` (текущий) | `slxGj3G3FAD7l5ms2tJ9` |
|
||||||
|
|
||||||
|
### Цепочка отказа
|
||||||
|
|
||||||
|
1. `getRouterToken()` → `POST /auth/login` со старым паролем → **401 Unauthorized**
|
||||||
|
2. Fallback: `return s.readSAToken()` → Kubernetes SA token, `alg: RS256`
|
||||||
|
3. Console отправляет `Authorization: Bearer <RS256-SA-token>` в router
|
||||||
|
4. Router keyfunc: `return []byte(JWT_SIGNING_KEY), nil` — возвращает `[]byte` для **любого** алгоритма
|
||||||
|
5. `jwt.Parse` получает RS256 JWT но ключ `[]byte` (ожидается `*rsa.PublicKey`) → **"key is of invalid type"**
|
||||||
|
|
||||||
|
### Подтверждено
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Старый пароль → 401:
|
||||||
|
kubectl exec -n fission fission-console-874c786c6-j6fvd -- wget -qO- \
|
||||||
|
--post-data='{"username":"admin","password":"ToAjBTs0Sb8mLC1J9NN3"}' \
|
||||||
|
--header='Content-Type: application/json' \
|
||||||
|
http://router.fission.svc.cluster.local/auth/login
|
||||||
|
# → HTTP/1.1 401 Unauthorized
|
||||||
|
|
||||||
|
# Новый пароль → OK:
|
||||||
|
# password=slxGj3G3FAD7l5ms2tJ9 → {"accesstoken":"eyJhbGciOiJIUzI1NiI..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что нужно исправить
|
||||||
|
|
||||||
|
### Файл 1: `console/internal/api/server.go` — функция `getRouterToken()`
|
||||||
|
|
||||||
|
**Проблема A:** читает пароль из env var один раз при старте → устаревает при ротации secret.
|
||||||
|
**Проблема B:** при ошибке login молча возвращает SA token вместо ошибки.
|
||||||
|
|
||||||
|
**Нужно:**
|
||||||
|
- Читать username/password из **файла** при каждом вызове `getRouterToken()`, а не из `os.Getenv` при старте.
|
||||||
|
Путь файла: `/etc/fission-router-secret/username` и `/etc/fission-router-secret/password`
|
||||||
|
- При ошибке login — возвращать `("", error)`, не `readSAToken()`.
|
||||||
|
Caller (`handleInvokeFunction`) должен вернуть 503 с понятным сообщением.
|
||||||
|
|
||||||
|
### Файл 2: `console/deploy/console.yaml`
|
||||||
|
|
||||||
|
**Нужно:** заменить `env` → `secretKeyRef` на volume mount.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Убрать из env:
|
||||||
|
- name: FISSION_AUTH_USERNAME
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: router
|
||||||
|
key: username
|
||||||
|
- name: FISSION_AUTH_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: router
|
||||||
|
key: password
|
||||||
|
|
||||||
|
# Добавить volume:
|
||||||
|
volumes:
|
||||||
|
- name: router-secret
|
||||||
|
secret:
|
||||||
|
secretName: router
|
||||||
|
items:
|
||||||
|
- key: username
|
||||||
|
path: username
|
||||||
|
- key: password
|
||||||
|
path: password
|
||||||
|
|
||||||
|
# Добавить volumeMount:
|
||||||
|
volumeMounts:
|
||||||
|
- name: router-secret
|
||||||
|
mountPath: /etc/fission-router-secret
|
||||||
|
readOnly: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Kubernetes обновляет смонтированные secret-файлы автоматически в течение ~60с после изменения secret.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Порядок работы
|
||||||
|
|
||||||
|
1. Прочитать текущий код `getRouterToken()` в `console/internal/api/server.go`
|
||||||
|
2. Найти где инициализируются `s.authUser` / `s.authPass` (вероятно в `NewServer()` или аналоге)
|
||||||
|
3. Убрать сохранение в struct, читать из файла на каждый вызов `getRouterToken()`
|
||||||
|
4. Изменить возврат при ошибке login: `return "", fmt.Errorf("router login failed: %w", err)` вместо `readSAToken()`
|
||||||
|
5. Найти всех callers `getRouterToken()` — обработать ошибку (вернуть 503)
|
||||||
|
6. Обновить `console/deploy/console.yaml` (volume mount вместо env)
|
||||||
|
7. Увеличить тег образа (согласно правилам: vX.Y.Z → vX.Y.Z+1)
|
||||||
|
8. rsync → build → push → apply
|
||||||
|
|
||||||
|
## Текущий тег образа
|
||||||
|
|
||||||
|
Проверить: `grep "naeel/fission-console" console/deploy/console.yaml`
|
||||||
|
Последний известный: `v1.3.92`
|
||||||
|
|
||||||
|
## SSH / rsync
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# rsync:
|
||||||
|
rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
|
||||||
|
~/fission-console/ naeel@5.172.178.213:~/terra/fission/
|
||||||
|
|
||||||
|
# SSH:
|
||||||
|
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
|
||||||
|
```
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# FIX: archivePruner удаляет архивы tenant namespaces
|
||||||
|
|
||||||
|
## Симптом
|
||||||
|
|
||||||
|
Node.js функции не специализируются → fetcher 404 при скачивании архива.
|
||||||
|
Storagesvc логи: `archive_pruner` удаляет архив через ~90 сек после создания функции.
|
||||||
|
|
||||||
|
## Причина
|
||||||
|
|
||||||
|
`pkg/storagesvc/archivePruner.go`, функция `getOrphanArchives`, строка 95:
|
||||||
|
|
||||||
|
```go
|
||||||
|
for _, namespace := range utils.DefaultNSResolver().Snapshot() {
|
||||||
|
pkgList, err := pruner.crdClient.CoreV1().Packages(namespace).List(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
`DefaultNSResolver().Snapshot()` возвращает только namespaces зарегистрированные
|
||||||
|
через `AddNamespace()`. Storagesvc не слушает namespace events → tenant namespaces
|
||||||
|
(`fission-*`) туда не попадают → их Package CRD невидимы → все загруженные архивы
|
||||||
|
считаются orphan → удаляются.
|
||||||
|
|
||||||
|
## Правка
|
||||||
|
|
||||||
|
**Файл:** `pkg/storagesvc/archivePruner.go`
|
||||||
|
|
||||||
|
**Заменить** (строки 94–125):
|
||||||
|
|
||||||
|
```go
|
||||||
|
// get all pkgs from kubernetes
|
||||||
|
for _, namespace := range utils.DefaultNSResolver().Snapshot() {
|
||||||
|
pkgList, err := pruner.crdClient.CoreV1().Packages(namespace).List(ctx, metav1.ListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
pruner.logger.Error("error getting package list from kubernetes", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// extract archives referenced by these pkgs
|
||||||
|
for _, pkg := range pkgList.Items {
|
||||||
|
if pkg.Spec.Deployment.URL != "" {
|
||||||
|
archiveID, err = getQueryParamValue(pkg.Spec.Deployment.URL, "id")
|
||||||
|
if err != nil {
|
||||||
|
pruner.logger.Error("error extracting value of archiveID from deployment url",
|
||||||
|
zap.Error(err),
|
||||||
|
zap.String("url", pkg.Spec.Deployment.URL))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
archivesRefByPkgs = append(archivesRefByPkgs, archiveID)
|
||||||
|
}
|
||||||
|
if pkg.Spec.Source.URL != "" {
|
||||||
|
archiveID, err = getQueryParamValue(pkg.Spec.Source.URL, "id")
|
||||||
|
if err != nil {
|
||||||
|
pruner.logger.Error("error extracting value of archiveID from source url",
|
||||||
|
zap.Error(err),
|
||||||
|
zap.String("url", pkg.Spec.Source.URL))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
archivesRefByPkgs = append(archivesRefByPkgs, archiveID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**На:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// get all pkgs from kubernetes across ALL namespaces (including tenant namespaces)
|
||||||
|
// Fix: DefaultNSResolver().Snapshot() returns only fission-registered namespaces,
|
||||||
|
// not tenant namespaces (fission-*). Use NamespaceAll to scan everything.
|
||||||
|
pkgList, err := pruner.crdClient.CoreV1().Packages(metav1.NamespaceAll).List(ctx, metav1.ListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
pruner.logger.Error("error getting package list from kubernetes", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// extract archives referenced by these pkgs
|
||||||
|
for _, pkg := range pkgList.Items {
|
||||||
|
if pkg.Spec.Deployment.URL != "" {
|
||||||
|
archiveID, err = getQueryParamValue(pkg.Spec.Deployment.URL, "id")
|
||||||
|
if err != nil {
|
||||||
|
pruner.logger.Error("error extracting value of archiveID from deployment url",
|
||||||
|
zap.Error(err),
|
||||||
|
zap.String("url", pkg.Spec.Deployment.URL))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
archivesRefByPkgs = append(archivesRefByPkgs, archiveID)
|
||||||
|
}
|
||||||
|
if pkg.Spec.Source.URL != "" {
|
||||||
|
archiveID, err = getQueryParamValue(pkg.Spec.Source.URL, "id")
|
||||||
|
if err != nil {
|
||||||
|
pruner.logger.Error("error extracting value of archiveID from source url",
|
||||||
|
zap.Error(err),
|
||||||
|
zap.String("url", pkg.Spec.Source.URL))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
archivesRefByPkgs = append(archivesRefByPkgs, archiveID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Импорты** — удалить `"github.com/fission/fission/pkg/utils"` (больше не используется).
|
||||||
|
|
||||||
|
## После правки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/terra/fission-src
|
||||||
|
go build ./pkg/storagesvc/... # проверить компиляцию
|
||||||
|
|
||||||
|
# пересобрать storagesvc образ и передеплоить
|
||||||
|
docker build -t naeel/fission-storagesvc:<новый-тег> -f cmd/storagesvc/Dockerfile .
|
||||||
|
docker push naeel/fission-storagesvc:<новый-тег>
|
||||||
|
kubectl set image -n fission deploy/storagesvc storagesvc=naeel/fission-storagesvc:<новый-тег>
|
||||||
|
kubectl rollout status -n fission deploy/storagesvc
|
||||||
|
```
|
||||||
@@ -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` — полный справочник контрактов.
|
||||||
|
|
||||||
|
|||||||
@@ -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-код функции.
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
MQ-триггер (MessageQueue) — запуск при появлении сообщения в очереди (Kafka, NATS, RabbitMQ и т.д.)
|
# TODO / Статус фич (актуально: 2026-05-21)
|
||||||
Kube-Watch триггер — запуск при изменении объектов в Kubernetes - а это разве может быть реализовано ?
|
|
||||||
|
|
||||||
В Fission — да, это штатные встроенные типы триггеров. Fission из коробки поддерживает MQ (Kafka, NATS, Azure Queue) и Kube-Watch через отдельные компоненты (kubewatcher, mqtrigger).
|
✅ = реализовано, ❌ = нет, ⚠️ = частично
|
||||||
|
|
||||||
Но в вашей консоли это не реализовано — нет UI для создания/управления этими триггерами. Только HTTP и Cron. Если потребуется — можно добавить.
|
### Триггеры
|
||||||
|
✅ HTTP Trigger — автосоздание при create function
|
||||||
|
✅ Time Trigger (Cron) — CRUD
|
||||||
|
✅ MQ Trigger (SQS) — sqs-consumer Deployment + Secret
|
||||||
|
✅ Kubernetes Watch Trigger — CRUD
|
||||||
|
|
||||||
от что реально полезно и не очень сложно:
|
### Функции
|
||||||
|
✅ Клонирование (v1.3.80)
|
||||||
|
✅ Переменные окружения (v1.3.71)
|
||||||
|
✅ Зависимости deps (requirements.txt/Gemfile/composer.json)
|
||||||
|
✅ AI: проверка синтаксиса (реальные линтеры)
|
||||||
|
✅ AI: объяснение архива (LLM)
|
||||||
|
✅ AI: генерация кода (LLM)
|
||||||
|
|
||||||
Для пользователя:
|
### Статистика
|
||||||
|
✅ Billing в PostgreSQL
|
||||||
|
✅ Grafana per namespace
|
||||||
|
|
||||||
История вызовов — последние N вызовов функции: время, статус, время ответа. Сейчас вызвал и забыл.
|
### Не реализовано
|
||||||
Метрики — счётчик вызовов, средний latency, % ошибок. У вас уже есть metrics-collector — данные есть, нужен только UI.
|
❌ Экспорт/импорт функций (zip)
|
||||||
Автообновление логов — сейчас нужно жать "Обновить" вручную. Можно автополлинг каждые 3-5 сек.
|
❌ Автообновление логов (polling)
|
||||||
Копировать маршрут одной кнопкой — иконка 📋 рядом с маршрутом в таблице.
|
❌ Уведомления при ошибках
|
||||||
Статус функции — Ready/NotReady/Error прямо в таблице (сейчас не видно).
|
|
||||||
Для управления:
|
|
||||||
6. Клонировать функцию — создать копию с новым именем, сохранив код и настройки.
|
|
||||||
7. Экспорт/импорт — скачать функцию как zip, загрузить обратно.
|
|
||||||
8. Переменные окружения — env vars для функции через UI (сейчас нельзя задать).
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
@@ -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 "════════════════════════════════════════════════════════════════"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
def main(event, context):
|
||||||
|
return {
|
||||||
|
"statusCode": 200,
|
||||||
|
"body": "hello, world!"
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"fission-console/internal/api"
|
"fission-console/internal/api"
|
||||||
"fission-console/internal/auth"
|
"fission-console/internal/auth"
|
||||||
"fission-console/internal/billing"
|
"fission-console/internal/billing"
|
||||||
|
"fission-console/internal/stats"
|
||||||
|
|
||||||
"k8s.io/client-go/dynamic"
|
"k8s.io/client-go/dynamic"
|
||||||
"k8s.io/client-go/kubernetes"
|
"k8s.io/client-go/kubernetes"
|
||||||
@@ -50,6 +51,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
billingStore := billing.NewStore()
|
billingStore := billing.NewStore()
|
||||||
|
statsProvider := stats.NewProvider()
|
||||||
|
|
||||||
srv := api.NewServer(api.Config{
|
srv := api.NewServer(api.Config{
|
||||||
Dyn: dyn,
|
Dyn: dyn,
|
||||||
@@ -68,6 +70,7 @@ func main() {
|
|||||||
LLMKey: os.Getenv("FISSION_LLM_KEY"),
|
LLMKey: os.Getenv("FISSION_LLM_KEY"),
|
||||||
// --- end ai/ask feature ---
|
// --- end ai/ask feature ---
|
||||||
Billing: billingStore,
|
Billing: billingStore,
|
||||||
|
Stats: statsProvider,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Запускаем фоновые горутины: reaper истёкших функций
|
// Запускаем фоновые горутины: reaper истёкших функций
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ rules:
|
|||||||
- apiGroups: [""]
|
- apiGroups: [""]
|
||||||
resources: ["pods/log"]
|
resources: ["pods/log"]
|
||||||
verbs: ["get"]
|
verbs: ["get"]
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["secrets"]
|
||||||
|
verbs: ["get", "list", "create", "delete"]
|
||||||
- apiGroups: ["apps"]
|
- apiGroups: ["apps"]
|
||||||
resources: ["deployments"]
|
resources: ["deployments"]
|
||||||
verbs: ["get", "list", "update", "patch"]
|
verbs: ["get", "list", "create", "update", "patch", "delete"]
|
||||||
- apiGroups: ["fission.io"]
|
- apiGroups: ["fission.io"]
|
||||||
resources: ["environments", "packages", "functions", "httptriggers", "timetriggers"]
|
resources: ["environments", "packages", "functions", "httptriggers", "timetriggers"]
|
||||||
verbs: ["get", "list", "create", "update", "patch", "delete"]
|
verbs: ["get", "list", "create", "update", "patch", "delete"]
|
||||||
@@ -55,7 +58,7 @@ spec:
|
|||||||
serviceAccountName: fission-console
|
serviceAccountName: fission-console
|
||||||
containers:
|
containers:
|
||||||
- name: console
|
- name: console
|
||||||
image: naeel/fission-console:v1.3.86
|
image: naeel/fission-console:v1.3.96
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8090
|
- containerPort: 8090
|
||||||
@@ -84,6 +87,14 @@ spec:
|
|||||||
value: "http://storagesvc.fission.svc.cluster.local"
|
value: "http://storagesvc.fission.svc.cluster.local"
|
||||||
- name: BILLING_DSN
|
- name: BILLING_DSN
|
||||||
value: "postgres://super:BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif@postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local:5432/sqsdb"
|
value: "postgres://super:BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif@postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local:5432/sqsdb"
|
||||||
|
- name: GRAFANA_INTERNAL_URL
|
||||||
|
value: "http://grafana.grafana.svc.cluster.local:3000"
|
||||||
|
- name: GRAFANA_PUBLIC_URL
|
||||||
|
value: "https://fission.kube5s.ru/grafana"
|
||||||
|
- name: GRAFANA_ADMIN_USER
|
||||||
|
value: "admin"
|
||||||
|
- name: GRAFANA_ADMIN_PASS
|
||||||
|
value: "GrafanaAdmin2026!"
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /health
|
path: /health
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -181,6 +181,13 @@ func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) {
|
|||||||
fmt.Printf("handleAuth: ensureUserNS %s: %v\n", ns, ensureErr)
|
fmt.Printf("handleAuth: ensureUserNS %s: %v\n", ns, ensureErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Провизируем Grafana Org для этого namespace (fire-and-forget, идемпотентно).
|
||||||
|
go func() {
|
||||||
|
if err := s.stats.EnsureOrgForNamespace(context.Background(), ns, identity.Email); err != nil {
|
||||||
|
fmt.Printf("handleAuth: EnsureOrgForNamespace %s: %v\n", ns, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "env": env, "namespace": ns, "email": identity.Email})
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "env": env, "namespace": ns, "email": identity.Email})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:])
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"fission-console/internal/billing"
|
"fission-console/internal/billing"
|
||||||
"fission-console/internal/cloud"
|
"fission-console/internal/cloud"
|
||||||
"fission-console/internal/fission"
|
"fission-console/internal/fission"
|
||||||
|
"fission-console/internal/stats"
|
||||||
"fission-console/ui"
|
"fission-console/ui"
|
||||||
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
@@ -63,6 +64,9 @@ type Server struct {
|
|||||||
|
|
||||||
// billing — слой записи статистики вызовов. NoopStore если BILLING_DSN не задан.
|
// billing — слой записи статистики вызовов. NoopStore если BILLING_DSN не задан.
|
||||||
billing billing.Store
|
billing billing.Store
|
||||||
|
|
||||||
|
// stats — аналитический слой (Grafana Organizations). NoopProvider если не настроен.
|
||||||
|
stats stats.StatsProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config содержит все параметры для создания Server.
|
// Config содержит все параметры для создания Server.
|
||||||
@@ -80,7 +84,8 @@ type Config struct {
|
|||||||
Authenticator auth.Authenticator // слой аутентификации
|
Authenticator auth.Authenticator // слой аутентификации
|
||||||
LLMUrl string
|
LLMUrl string
|
||||||
LLMKey string
|
LLMKey string
|
||||||
Billing billing.Store // слой статистики (NoopStore если не задан)
|
Billing billing.Store // слой статистики (NoopStore если не задан)
|
||||||
|
Stats stats.StatsProvider // аналитика (NoopProvider если не настроен)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer создаёт и настраивает HTTP Server со всеми зависимостями.
|
// NewServer создаёт и настраивает HTTP Server со всеми зависимостями.
|
||||||
@@ -102,6 +107,7 @@ func NewServer(cfg Config) *Server {
|
|||||||
llmKey: cfg.LLMKey,
|
llmKey: cfg.LLMKey,
|
||||||
nsManager: cloud.NewNSManager(cfg.Dyn),
|
nsManager: cloud.NewNSManager(cfg.Dyn),
|
||||||
billing: cfg.Billing,
|
billing: cfg.Billing,
|
||||||
|
stats: cfg.Stats,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,8 +169,13 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/console/api/httptriggers", auth(s.handleList(fission.HTTPTrigGVR)))
|
mux.HandleFunc("/console/api/httptriggers", auth(s.handleList(fission.HTTPTrigGVR)))
|
||||||
mux.HandleFunc("/console/api/timetriggers", auth(s.handleTimeTriggersRoot))
|
mux.HandleFunc("/console/api/timetriggers", auth(s.handleTimeTriggersRoot))
|
||||||
mux.HandleFunc("/console/api/timetriggers/", auth(s.handleTimeTriggersAction))
|
mux.HandleFunc("/console/api/timetriggers/", auth(s.handleTimeTriggersAction))
|
||||||
|
mux.HandleFunc("/console/api/mqtriggers", auth(s.handleMQTriggersRoot))
|
||||||
|
mux.HandleFunc("/console/api/mqtriggers/", auth(s.handleMQTriggersAction))
|
||||||
|
mux.HandleFunc("/console/api/kwtriggers", auth(s.handleKWTriggersRoot))
|
||||||
|
mux.HandleFunc("/console/api/kwtriggers/", auth(s.handleKWTriggersAction))
|
||||||
mux.HandleFunc("/console/api/ns/status", auth(s.handleNSStatus))
|
mux.HandleFunc("/console/api/ns/status", auth(s.handleNSStatus))
|
||||||
mux.HandleFunc("/console/api/ns/debug", auth(s.handleNSDebug))
|
mux.HandleFunc("/console/api/ns/debug", auth(s.handleNSDebug))
|
||||||
|
mux.HandleFunc("/console/api/stats/dashboard-url", auth(s.handleStatsDashboard))
|
||||||
mux.HandleFunc("/console/api/ai/check", auth(s.handleAICheck))
|
mux.HandleFunc("/console/api/ai/check", auth(s.handleAICheck))
|
||||||
mux.HandleFunc("/console/api/ai/lint-archive", auth(s.handleLintArchive))
|
mux.HandleFunc("/console/api/ai/lint-archive", auth(s.handleLintArchive))
|
||||||
mux.HandleFunc("/console/api/ai/explain-archive", auth(s.handleExplainArchive))
|
mux.HandleFunc("/console/api/ai/explain-archive", auth(s.handleExplainArchive))
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleStatsDashboard GET /console/api/stats/dashboard-url
|
||||||
|
// Возвращает публичный URL дашборда Grafana для текущего namespace пользователя.
|
||||||
|
// Если аналитика не настроена — возвращает {"url":""}.
|
||||||
|
func (s *Server) handleStatsDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ns := s.userNS(r)
|
||||||
|
url := s.stats.DashboardURL(r.Context(), ns)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"url": url,
|
||||||
|
"namespace": ns,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -25,3 +25,28 @@ func buildZip(fileName string, content []byte) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
return buf.Bytes(), nil
|
return buf.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildZipTwo создаёт zip-архив с двумя файлами.
|
||||||
|
// Используется когда пользователь указал файл зависимостей (requirements.txt и т.д.).
|
||||||
|
func buildZipTwo(file1, file2 string, content1, content2 []byte) ([]byte, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
zw := zip.NewWriter(&buf)
|
||||||
|
|
||||||
|
for _, f := range []struct {
|
||||||
|
name string
|
||||||
|
content []byte
|
||||||
|
}{{file1, content1}, {file2, content2}} {
|
||||||
|
fw, err := zw.Create(f.name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := fw.Write(f.content); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := zw.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,3 +11,20 @@ package runtime
|
|||||||
func BuildScriptZip(code, fileName string) ([]byte, error) {
|
func BuildScriptZip(code, fileName string) ([]byte, error) {
|
||||||
return buildZip(fileName, []byte(code))
|
return buildZip(fileName, []byte(code))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BuildScriptZipWithDeps создаёт zip с кодом и файлом зависимостей.
|
||||||
|
// fileName — имя файла кода (handler.rb, main.php и т.д.)
|
||||||
|
// depsName — имя файла зависимостей (Gemfile, composer.json и т.д.)
|
||||||
|
func BuildScriptZipWithDeps(code, fileName, deps, depsName string) ([]byte, error) {
|
||||||
|
return buildZipTwo(fileName, depsName, []byte(code), []byte(deps))
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildPythonZip создаёт zip с main.py (и опционально requirements.txt).
|
||||||
|
// Если deps пустой — возвращает raw bytes кода (текущее поведение Python).
|
||||||
|
// Если deps задан — zip с main.py + requirements.txt для pip install.
|
||||||
|
func BuildPythonZip(code, deps string) ([]byte, error) {
|
||||||
|
if deps == "" {
|
||||||
|
return []byte(code), nil
|
||||||
|
}
|
||||||
|
return buildZipTwo("main.py", "requirements.txt", []byte(code), []byte(deps))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package stats
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewProvider создаёт StatsProvider из переменных окружения.
|
||||||
|
//
|
||||||
|
// Переменные:
|
||||||
|
// - GRAFANA_INTERNAL_URL — внутренний URL (http://grafana.grafana.svc.cluster.local:3000)
|
||||||
|
// - GRAFANA_PUBLIC_URL — публичный URL для ссылок (https://fission.kube5s.ru/grafana)
|
||||||
|
// - GRAFANA_ADMIN_USER — имя admin (default: "admin")
|
||||||
|
// - GRAFANA_ADMIN_PASS — пароль admin
|
||||||
|
func NewProvider() StatsProvider {
|
||||||
|
internalURL := strings.TrimSpace(os.Getenv("GRAFANA_INTERNAL_URL"))
|
||||||
|
if internalURL == "" {
|
||||||
|
log.Printf("stats: GRAFANA_INTERNAL_URL not set — using NoopProvider")
|
||||||
|
return NoopProvider{}
|
||||||
|
}
|
||||||
|
|
||||||
|
publicURL := strings.TrimSpace(os.Getenv("GRAFANA_PUBLIC_URL"))
|
||||||
|
if publicURL == "" {
|
||||||
|
publicURL = "https://fission.kube5s.ru/grafana"
|
||||||
|
}
|
||||||
|
|
||||||
|
adminUser := strings.TrimSpace(os.Getenv("GRAFANA_ADMIN_USER"))
|
||||||
|
if adminUser == "" {
|
||||||
|
adminUser = "admin"
|
||||||
|
}
|
||||||
|
adminPass := os.Getenv("GRAFANA_ADMIN_PASS")
|
||||||
|
|
||||||
|
log.Printf("stats: GrafanaProvider internalURL=%s publicURL=%s user=%s", internalURL, publicURL, adminUser)
|
||||||
|
return NewGrafanaProvider(internalURL, publicURL, adminUser, adminPass)
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
package stats
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GrafanaProvider реализует StatsProvider через Grafana HTTP API.
|
||||||
|
//
|
||||||
|
// Для каждого namespace создаётся изолированная Grafana Organization:
|
||||||
|
// - PostgreSQL datasource (тот же DSN, uid="fission-user-pg")
|
||||||
|
// - Dashboard с hardcoded WHERE namespace='...'
|
||||||
|
// - Public Dashboard (без логина) → accessToken
|
||||||
|
//
|
||||||
|
// Потокобезопасен: sync.RWMutex + per-namespace singleflight.
|
||||||
|
type GrafanaProvider struct {
|
||||||
|
internalURL string // http://grafana.grafana.svc.cluster.local:3000
|
||||||
|
publicURL string // https://fission.kube5s.ru/grafana
|
||||||
|
adminUser string
|
||||||
|
adminPass string
|
||||||
|
http *http.Client
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
tokens map[string]string // namespace → publicDashboardAccessToken
|
||||||
|
orgIDs map[string]int64 // namespace → grafana orgId
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGrafanaProvider создаёт GrafanaProvider.
|
||||||
|
func NewGrafanaProvider(internalURL, publicURL, adminUser, adminPass string) *GrafanaProvider {
|
||||||
|
return &GrafanaProvider{
|
||||||
|
internalURL: strings.TrimRight(internalURL, "/"),
|
||||||
|
publicURL: strings.TrimRight(publicURL, "/"),
|
||||||
|
adminUser: adminUser,
|
||||||
|
adminPass: adminPass,
|
||||||
|
http: &http.Client{Timeout: 20 * time.Second},
|
||||||
|
tokens: make(map[string]string),
|
||||||
|
orgIDs: make(map[string]int64),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureOrgForNamespace идемпотентно создаёт Grafana Org + datasource + dashboard + public link.
|
||||||
|
func (g *GrafanaProvider) EnsureOrgForNamespace(ctx context.Context, namespace, email string) error {
|
||||||
|
// Быстрый путь: уже провизировано в этом процессе
|
||||||
|
g.mu.RLock()
|
||||||
|
_, cached := g.tokens[namespace]
|
||||||
|
g.mu.RUnlock()
|
||||||
|
if cached {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Шаг 1: получить или создать Org
|
||||||
|
orgID, err := g.getOrCreateOrg(ctx, namespace)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getOrCreateOrg(%s): %w", namespace, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Шаг 2: создать datasource в этой Org (идемпотентно)
|
||||||
|
if err := g.ensureDatasource(ctx, orgID); err != nil {
|
||||||
|
log.Printf("stats: ensureDatasource org=%d ns=%s: %v", orgID, namespace, err)
|
||||||
|
// не фатально — dashboard может не работать но org создана
|
||||||
|
}
|
||||||
|
|
||||||
|
// Шаг 3: создать dashboard с hardcoded namespace (идемпотентно)
|
||||||
|
dashUID, err := g.ensureDashboard(ctx, orgID, namespace)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ensureDashboard org=%d ns=%s: %w", orgID, namespace, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Шаг 4: получить или создать public dashboard → accessToken
|
||||||
|
token, err := g.ensurePublicDashboard(ctx, orgID, dashUID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ensurePublicDashboard org=%d dash=%s: %w", orgID, dashUID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кэшируем
|
||||||
|
g.mu.Lock()
|
||||||
|
g.tokens[namespace] = token
|
||||||
|
g.orgIDs[namespace] = orgID
|
||||||
|
g.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("stats: org provisioned ns=%s orgId=%d publicToken=%s...", namespace, orgID, token[:8])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DashboardURL возвращает публичный URL или "" если ещё не провизировано.
|
||||||
|
func (g *GrafanaProvider) DashboardURL(ctx context.Context, namespace string) string {
|
||||||
|
// Сначала пробуем из кэша
|
||||||
|
g.mu.RLock()
|
||||||
|
token, ok := g.tokens[namespace]
|
||||||
|
g.mu.RUnlock()
|
||||||
|
if ok && token != "" {
|
||||||
|
return g.publicURL + "/public-dashboards/" + token
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кэш промах (после перезапуска сервера) — провизируем заново
|
||||||
|
if err := g.EnsureOrgForNamespace(ctx, namespace, ""); err != nil {
|
||||||
|
log.Printf("stats: DashboardURL re-provision ns=%s: %v", namespace, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
g.mu.RLock()
|
||||||
|
token = g.tokens[namespace]
|
||||||
|
g.mu.RUnlock()
|
||||||
|
if token == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return g.publicURL + "/public-dashboards/" + token
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Grafana API helpers ---
|
||||||
|
|
||||||
|
// getOrCreateOrg возвращает orgId существующей или создаёт новую Org.
|
||||||
|
func (g *GrafanaProvider) getOrCreateOrg(ctx context.Context, namespace string) (int64, error) {
|
||||||
|
// Проверяем кэш orgIDs
|
||||||
|
g.mu.RLock()
|
||||||
|
if id, ok := g.orgIDs[namespace]; ok {
|
||||||
|
g.mu.RUnlock()
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
g.mu.RUnlock()
|
||||||
|
|
||||||
|
// GET /api/orgs/name/{namespace}
|
||||||
|
resp, body, err := g.grafanaRequest(ctx, http.MethodGet, "/api/orgs/name/"+namespace, 0, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
var org struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &org); err != nil {
|
||||||
|
return 0, fmt.Errorf("parse org: %w", err)
|
||||||
|
}
|
||||||
|
return org.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Org не найдена — создаём
|
||||||
|
payload := map[string]string{"name": namespace}
|
||||||
|
resp, body, err = g.grafanaRequest(ctx, http.MethodPost, "/api/orgs", 0, payload)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
return 0, fmt.Errorf("create org status=%d body=%s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
var created struct {
|
||||||
|
OrgID int64 `json:"orgId"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &created); err != nil {
|
||||||
|
return 0, fmt.Errorf("parse create org: %w", err)
|
||||||
|
}
|
||||||
|
return created.OrgID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureDatasource создаёт PostgreSQL datasource в org (uid="fission-user-pg").
|
||||||
|
// Идемпотентен: 409 Conflict считается успехом.
|
||||||
|
func (g *GrafanaProvider) ensureDatasource(ctx context.Context, orgID int64) error {
|
||||||
|
// Проверяем есть ли уже datasource в этой org
|
||||||
|
resp, _, err := g.grafanaRequest(ctx, http.MethodGet, "/api/datasources/uid/fission-user-pg", orgID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
return nil // уже есть
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем DSN из уже существующего datasource в Org 1 (uid=fission-pg)
|
||||||
|
_, body, err := g.grafanaRequest(ctx, http.MethodGet, "/api/datasources/uid/fission-pg", 1, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get main datasource: %w", err)
|
||||||
|
}
|
||||||
|
var ds struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
JSONData json.RawMessage `json:"jsonData"`
|
||||||
|
SecureJSONData struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
} `json:"secureJsonData"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &ds); err != nil {
|
||||||
|
return fmt.Errorf("parse main datasource: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём копию datasource в новой Org
|
||||||
|
payload := map[string]any{
|
||||||
|
"name": "fission-pg",
|
||||||
|
"type": "postgres",
|
||||||
|
"uid": "fission-user-pg",
|
||||||
|
"url": ds.URL,
|
||||||
|
"access": "proxy",
|
||||||
|
"jsonData": map[string]any{
|
||||||
|
"sslmode": "disable",
|
||||||
|
"postgresVersion": 1700,
|
||||||
|
"timescaledb": false,
|
||||||
|
},
|
||||||
|
"secureJsonData": ds.SecureJSONData,
|
||||||
|
}
|
||||||
|
resp, body, err = g.grafanaRequest(ctx, http.MethodPost, "/api/datasources", orgID, payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusConflict {
|
||||||
|
return nil // уже существует
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
return fmt.Errorf("create datasource status=%d body=%s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureDashboard создаёт/обновляет user-дашборд в org.
|
||||||
|
// Возвращает uid дашборда.
|
||||||
|
func (g *GrafanaProvider) ensureDashboard(ctx context.Context, orgID int64, namespace string) (string, error) {
|
||||||
|
const dashUID = "fission-user-overview"
|
||||||
|
|
||||||
|
// Проверяем существование
|
||||||
|
resp, _, err := g.grafanaRequest(ctx, http.MethodGet, "/api/dashboards/uid/"+dashUID, orgID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
return dashUID, nil // уже есть
|
||||||
|
}
|
||||||
|
|
||||||
|
// Импортируем dashboard JSON с hardcoded namespace
|
||||||
|
dashJSON := userDashboardJSON(namespace)
|
||||||
|
payload := map[string]any{
|
||||||
|
"dashboard": json.RawMessage(dashJSON),
|
||||||
|
"overwrite": true,
|
||||||
|
"folderId": 0,
|
||||||
|
}
|
||||||
|
resp, body, err := g.grafanaRequest(ctx, http.MethodPost, "/api/dashboards/db", orgID, payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("import dashboard status=%d body=%s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return dashUID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensurePublicDashboard создаёт public dashboard и возвращает accessToken.
|
||||||
|
// Идемпотентен: если уже существует — возвращает существующий token.
|
||||||
|
func (g *GrafanaProvider) ensurePublicDashboard(ctx context.Context, orgID int64, dashUID string) (string, error) {
|
||||||
|
path := "/api/dashboards/uid/" + dashUID + "/public-dashboards"
|
||||||
|
|
||||||
|
// Проверяем существование
|
||||||
|
resp, body, err := g.grafanaRequest(ctx, http.MethodGet, path, orgID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
var pd struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &pd); err != nil {
|
||||||
|
return "", fmt.Errorf("parse public dashboard: %w", err)
|
||||||
|
}
|
||||||
|
if pd.AccessToken != "" {
|
||||||
|
return pd.AccessToken, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём
|
||||||
|
payload := map[string]any{
|
||||||
|
"isEnabled": true,
|
||||||
|
"annotationsEnabled": false,
|
||||||
|
"timeSelectionEnabled": true,
|
||||||
|
}
|
||||||
|
resp, body, err = g.grafanaRequest(ctx, http.MethodPost, path, orgID, payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
return "", fmt.Errorf("create public dashboard status=%d body=%s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var pd struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &pd); err != nil {
|
||||||
|
return "", fmt.Errorf("parse created public dashboard: %w", err)
|
||||||
|
}
|
||||||
|
if pd.AccessToken == "" {
|
||||||
|
return "", fmt.Errorf("empty accessToken in response: %s", string(body))
|
||||||
|
}
|
||||||
|
return pd.AccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// grafanaRequest выполняет HTTP запрос к Grafana API.
|
||||||
|
// orgID > 0 → устанавливает X-Grafana-Org-Id заголовок (thread-safe, без смены контекста).
|
||||||
|
// orgID == 0 → без заголовка (используется Org 1 admin по умолчанию).
|
||||||
|
func (g *GrafanaProvider) grafanaRequest(ctx context.Context, method, path string, orgID int64, payload any) (*http.Response, []byte, error) {
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if payload != nil {
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
bodyReader = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, g.internalURL+path, bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("new request: %w", err)
|
||||||
|
}
|
||||||
|
req.SetBasicAuth(g.adminUser, g.adminPass)
|
||||||
|
if payload != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if orgID > 0 {
|
||||||
|
req.Header.Set("X-Grafana-Org-Id", fmt.Sprintf("%d", orgID))
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := g.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("do request %s %s: %w", method, path, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return resp, nil, fmt.Errorf("read body: %w", err)
|
||||||
|
}
|
||||||
|
return resp, body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// userDashboardJSON генерирует JSON дашборда для конкретного namespace.
|
||||||
|
// Namespace вшит прямо в SQL запросы — без template variables.
|
||||||
|
// Dashboard uid="fission-user-overview" (per-org, без конфликтов между org).
|
||||||
|
func userDashboardJSON(namespace string) string {
|
||||||
|
// Безопасное экранирование namespace для SQL (namespace это sha256 hex — только [a-z0-9-])
|
||||||
|
ns := strings.ReplaceAll(namespace, "'", "''")
|
||||||
|
return fmt.Sprintf(`{
|
||||||
|
"title": "Мои функции — %s",
|
||||||
|
"uid": "fission-user-overview",
|
||||||
|
"tags": ["fission", "user"],
|
||||||
|
"timezone": "browser",
|
||||||
|
"refresh": "1m",
|
||||||
|
"time": {"from": "now-24h", "to": "now"},
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"id": 1, "title": "Вызовы в час", "type": "timeseries",
|
||||||
|
"gridPos": {"x": 0, "y": 0, "w": 16, "h": 8},
|
||||||
|
"datasource": {"type": "postgres", "uid": "fission-user-pg"},
|
||||||
|
"targets": [{"rawSql": "SELECT date_trunc('hour', started_at) AS time, count(*) AS value, function_name FROM invocations WHERE namespace = '%s' AND started_at BETWEEN $__timeFrom() AND $__timeTo() GROUP BY 1, function_name ORDER BY 1", "format": "time_series", "refId": "A"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2, "title": "Успех vs Ошибки", "type": "piechart",
|
||||||
|
"gridPos": {"x": 16, "y": 0, "w": 8, "h": 8},
|
||||||
|
"datasource": {"type": "postgres", "uid": "fission-user-pg"},
|
||||||
|
"targets": [{"rawSql": "SELECT CASE WHEN status_code >= 200 AND status_code < 300 THEN 'success' WHEN status_code = 0 THEN 'event' ELSE 'error' END AS metric, count(*) AS value FROM invocations WHERE namespace = '%s' AND started_at BETWEEN $__timeFrom() AND $__timeTo() GROUP BY 1", "format": "table", "refId": "A"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3, "title": "Топ функций", "type": "bargauge",
|
||||||
|
"gridPos": {"x": 0, "y": 8, "w": 12, "h": 8},
|
||||||
|
"datasource": {"type": "postgres", "uid": "fission-user-pg"},
|
||||||
|
"targets": [{"rawSql": "SELECT function_name AS metric, count(*) AS value FROM invocations WHERE namespace = '%s' AND started_at BETWEEN $__timeFrom() AND $__timeTo() GROUP BY 1 ORDER BY 2 DESC LIMIT 10", "format": "table", "refId": "A"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4, "title": "Средняя латентность (ms)", "type": "timeseries",
|
||||||
|
"gridPos": {"x": 12, "y": 8, "w": 12, "h": 8},
|
||||||
|
"datasource": {"type": "postgres", "uid": "fission-user-pg"},
|
||||||
|
"targets": [{"rawSql": "SELECT date_trunc('hour', started_at) AS time, round(avg(duration_ms)) AS avg_ms FROM invocations WHERE namespace = '%s' AND started_at BETWEEN $__timeFrom() AND $__timeTo() AND trigger_type != 'event' GROUP BY 1 ORDER BY 1", "format": "time_series", "refId": "A"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5, "title": "Последние события", "type": "table",
|
||||||
|
"gridPos": {"x": 0, "y": 16, "w": 24, "h": 8},
|
||||||
|
"datasource": {"type": "postgres", "uid": "fission-user-pg"},
|
||||||
|
"targets": [{"rawSql": "SELECT started_at AS time, function_name, trigger_type, event_type, status_code, duration_ms, error_msg FROM invocations WHERE namespace = '%s' ORDER BY started_at DESC LIMIT 50", "format": "table", "refId": "A"}]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"schemaVersion": 39
|
||||||
|
}`, namespace, ns, ns, ns, ns, ns)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package stats
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// NoopProvider — заглушка когда GRAFANA_INTERNAL_URL не задан.
|
||||||
|
// Ни на что не влияет, не крашит.
|
||||||
|
type NoopProvider struct{}
|
||||||
|
|
||||||
|
func (NoopProvider) EnsureOrgForNamespace(_ context.Context, _, _ string) error { return nil }
|
||||||
|
func (NoopProvider) DashboardURL(_ context.Context, _ string) string { return "" }
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Package stats — аналитический слой консоли.
|
||||||
|
//
|
||||||
|
// StatsProvider абстрагирует конкретный инструмент (Grafana, Prometheus, etc.).
|
||||||
|
// При смене инструмента достаточно заменить реализацию и переменные окружения.
|
||||||
|
package stats
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// StatsProvider — интерфейс аналитики.
|
||||||
|
// Реализации: GrafanaProvider (GRAFANA_INTERNAL_URL задан), NoopProvider (заглушка).
|
||||||
|
type StatsProvider interface {
|
||||||
|
// EnsureOrgForNamespace идемпотентно создаёт аналитическое пространство
|
||||||
|
// для namespace (Grafana Org + datasource + dashboard + public link).
|
||||||
|
// Вызывается при handleAuth — fire-and-forget горутиной.
|
||||||
|
EnsureOrgForNamespace(ctx context.Context, namespace, email string) error
|
||||||
|
|
||||||
|
// DashboardURL возвращает публичный URL дашборда без логина.
|
||||||
|
// Возвращает "" если аналитика не настроена или provisioning не завершён.
|
||||||
|
DashboardURL(ctx context.Context, namespace string) string
|
||||||
|
}
|
||||||
+86
-2
@@ -25,6 +25,7 @@
|
|||||||
<script src="js/fn-archive.js"></script>
|
<script src="js/fn-archive.js"></script>
|
||||||
<script src="js/ai.js"></script>
|
<script src="js/ai.js"></script>
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
|
<script src="js/mq.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -102,13 +103,14 @@
|
|||||||
<div class="nubes">NUBES</div>
|
<div class="nubes">NUBES</div>
|
||||||
<div class="product">FISSION CONSOLE</div>
|
<div class="product">FISSION CONSOLE</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.86</div>
|
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.96</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row" style="margin:0;">
|
<div class="row" style="margin:0;">
|
||||||
<button class="btn ghost" onclick="reloadAll()">Refresh</button>
|
<button class="btn ghost" onclick="reloadAll()">Refresh</button>
|
||||||
<button class="btn" onclick="openCreateCode()">✏️ Из кода</button>
|
<button class="btn" onclick="openCreateCode()">✏️ Из кода</button>
|
||||||
<button class="btn" onclick="openCreateArchive()">📦 Из архива</button>
|
<button class="btn" onclick="openCreateArchive()">📦 Из архива</button>
|
||||||
<button class="btn ghost" onclick="openHelp()">Help</button>
|
<button class="btn ghost" onclick="openHelp()">Help</button>
|
||||||
|
<button class="btn ghost" onclick="openAnalytics()" title="Открыть дашборд Grafana">📊 Аналитика</button>
|
||||||
<button class="btn ghost" onclick="doLogout()" style="margin-left:8px;">Выход</button>
|
<button class="btn ghost" onclick="doLogout()" style="margin-left:8px;">Выход</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,6 +137,10 @@
|
|||||||
<div class="k">Крон-функции</div>
|
<div class="k">Крон-функции</div>
|
||||||
<div id="cron-count" class="v">-</div>
|
<div id="cron-count" class="v">-</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="k">MQ-триггеры</div>
|
||||||
|
<div id="mq-count" class="v">-</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="box">
|
<div class="box">
|
||||||
@@ -173,6 +179,72 @@
|
|||||||
<div id="status" class="status"></div>
|
<div id="status" class="status"></div>
|
||||||
<div class="hint">Изменения применяются напрямую через CRD Fission.</div>
|
<div class="hint">Изменения применяются напрямую через CRD Fission.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- MQ-триггеры -->
|
||||||
|
<div class="box">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div style="font-weight:600;">MQ-триггеры</div>
|
||||||
|
<button class="btn" onclick="openCreateMQ()">+ Добавить</button>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Имя</th>
|
||||||
|
<th>Очередь</th>
|
||||||
|
<th>Функция</th>
|
||||||
|
<th>Endpoint SQS</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="mq-rows">
|
||||||
|
<tr><td colspan="6" style="color:var(--fg-muted,#888);text-align:center;">Загрузка...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="hint">MQ-триггер поллит очередь SQS и вызывает Fission-функцию при появлении сообщений.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модалка: создать MQ-триггер -->
|
||||||
|
<div id="mq-create-modal" class="modal">
|
||||||
|
<div class="panel" style="width:520px;max-width:96vw;">
|
||||||
|
<h3>📨 Создать MQ-триггер</h3>
|
||||||
|
<div class="row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Имя триггера</label>
|
||||||
|
<input id="mq-name" placeholder="weather-trigger">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Функция</label>
|
||||||
|
<input id="mq-fn" placeholder="weather-store">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Имя очереди (SQS)</label>
|
||||||
|
<input id="mq-queue" placeholder="weather-raw">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>SQS Endpoint</label>
|
||||||
|
<input id="mq-endpoint" placeholder="http://shared-sqs.shared-sqs.svc.cluster.local:4100">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Access Key</label>
|
||||||
|
<input id="mq-access-key" placeholder="SSAK-...">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Secret Key</label>
|
||||||
|
<input id="mq-secret-key" type="password" placeholder="...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="mq-create-error" class="modal-error"></div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn ghost" onclick="closeMQCreate()">Отмена</button>
|
||||||
|
<button class="btn" onclick="submitCreateMQ()">Создать</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Модалка: создать функцию из кода (prefix cc-) -->
|
<!-- Модалка: создать функцию из кода (prefix cc-) -->
|
||||||
@@ -234,6 +306,12 @@
|
|||||||
<button class="btn ghost" id="cc-gen-btn" onclick="showGenPrompt('cc')">✨ Сгенерировать код LLM</button>
|
<button class="btn ghost" id="cc-gen-btn" onclick="showGenPrompt('cc')">✨ Сгенерировать код LLM</button>
|
||||||
<button class="btn ghost" id="cc-exp-btn" onclick="aiExplain('cc-code','cc-lang','cc-ai-result')">📖 LLM: Что делает?</button>
|
<button class="btn ghost" id="cc-exp-btn" onclick="aiExplain('cc-code','cc-lang','cc-ai-result')">📖 LLM: Что делает?</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="margin-top:10px;">
|
||||||
|
<label id="cc-deps-label" style="font-size:12px; color:var(--text-secondary); margin-bottom:4px; display:block;">Зависимости (requirements.txt)</label>
|
||||||
|
<textarea id="cc-deps" rows="3" placeholder="boto3
|
||||||
|
requests>=2.28
|
||||||
|
psycopg2-binary" style="width:100%; box-sizing:border-box; font-family:monospace; font-size:12px; background:#1a1a2e; border:1px dashed #3a3a5c; border-radius:4px; color:#cdd6f4; padding:6px 8px; resize:none; overflow-y:auto;"></textarea>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="cc-gen-prompt" style="display:none; margin-top:8px; gap:6px; align-items:center;">
|
<div id="cc-gen-prompt" style="display:none; margin-top:8px; gap:6px; align-items:center;">
|
||||||
<input id="cc-gen-desc" type="text"
|
<input id="cc-gen-desc" type="text"
|
||||||
@@ -373,6 +451,12 @@
|
|||||||
<button class="btn ghost" id="e-exp-btn" onclick="aiExplain('e-code','e-lang-hidden','e-ai-result')">📖
|
<button class="btn ghost" id="e-exp-btn" onclick="aiExplain('e-code','e-lang-hidden','e-ai-result')">📖
|
||||||
LLM: Что делает?</button>
|
LLM: Что делает?</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="margin-top:10px;">
|
||||||
|
<label id="e-deps-label" style="font-size:12px; color:var(--text-secondary); margin-bottom:4px; display:block;">Зависимости (requirements.txt)</label>
|
||||||
|
<textarea id="e-deps" rows="3" placeholder="boto3
|
||||||
|
requests>=2.28
|
||||||
|
psycopg2-binary" style="width:100%; box-sizing:border-box; font-family:monospace; font-size:12px; background:#1a1a2e; border:1px dashed #3a3a5c; border-radius:4px; color:#cdd6f4; padding:6px 8px; resize:none; overflow-y:auto;"></textarea>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="e-archive-area" style="display:none;">
|
<div id="e-archive-area" style="display:none;">
|
||||||
<div id="e-archive-current" style="margin-bottom:10px; padding:8px 12px; background:var(--bg-alt); border-radius:6px; font-size:13px; color:var(--text-secondary);">
|
<div id="e-archive-current" style="margin-bottom:10px; padding:8px 12px; background:var(--bg-alt); border-radius:6px; font-size:13px; color:var(--text-secondary);">
|
||||||
@@ -542,7 +626,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions" style="justify-content:space-between; align-items:center;">
|
<div class="actions" style="justify-content:space-between; align-items:center;">
|
||||||
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.86</span>
|
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.96</span>
|
||||||
<button class="btn ghost" onclick="closeHelp()">Закрыть</button>
|
<button class="btn ghost" onclick="closeHelp()">Закрыть</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+57
-1
@@ -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 || [];
|
||||||
|
|||||||
@@ -93,6 +93,27 @@ function doLogout() {
|
|||||||
showLoginOverlay();
|
showLoginOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openAnalytics() {
|
||||||
|
var token = localStorage.getItem('auth_token');
|
||||||
|
var env = localStorage.getItem('auth_env') || 'test';
|
||||||
|
if (!token) { alert('Требуется авторизация'); return; }
|
||||||
|
try {
|
||||||
|
var r = await fetch(API_BASE + '/stats/dashboard-url', {
|
||||||
|
headers: { 'X-Auth-Token': token, 'X-Auth-Env': env }
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
var d = await r.json();
|
||||||
|
if (d.url) {
|
||||||
|
window.open(d.url, '_blank', 'noopener');
|
||||||
|
} else {
|
||||||
|
// Grafana не настроена или org ещё провизируется — ссылка на оператора
|
||||||
|
window.open('/grafana/', '_blank', 'noopener');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Аналитика временно недоступна: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function checkAuth() {
|
function checkAuth() {
|
||||||
var storedToken = localStorage.getItem('auth_token');
|
var storedToken = localStorage.getItem('auth_token');
|
||||||
if (!storedToken) {
|
if (!storedToken) {
|
||||||
|
|||||||
@@ -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)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: grafana-dashboard-fission
|
||||||
|
namespace: grafana
|
||||||
|
data:
|
||||||
|
fission-overview.json: |
|
||||||
|
{
|
||||||
|
"title": "Fission — Operator Overview",
|
||||||
|
"uid": "fission-overview",
|
||||||
|
"tags": ["fission"],
|
||||||
|
"timezone": "browser",
|
||||||
|
"refresh": "30s",
|
||||||
|
"time": { "from": "now-24h", "to": "now" },
|
||||||
|
"templating": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"name": "namespace",
|
||||||
|
"type": "query",
|
||||||
|
"datasource": { "type": "postgres", "uid": "fission-pg" },
|
||||||
|
"query": "SELECT DISTINCT namespace FROM invocations ORDER BY 1",
|
||||||
|
"includeAll": true,
|
||||||
|
"multi": true,
|
||||||
|
"label": "Namespace",
|
||||||
|
"current": { "text": "All", "value": "$__all" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Вызовы в час",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": { "x": 0, "y": 0, "w": 16, "h": 8 },
|
||||||
|
"datasource": { "type": "postgres", "uid": "fission-pg" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"rawSql": "SELECT date_trunc('hour', started_at) AS time, count(*) AS value, namespace FROM invocations WHERE started_at BETWEEN $__timeFrom() AND $__timeTo() AND ('$namespace' = '$__all' OR namespace = ANY(string_to_array('$namespace', ','))) GROUP BY 1, namespace ORDER BY 1",
|
||||||
|
"format": "time_series",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "Успех vs Ошибки",
|
||||||
|
"type": "piechart",
|
||||||
|
"gridPos": { "x": 16, "y": 0, "w": 8, "h": 8 },
|
||||||
|
"datasource": { "type": "postgres", "uid": "fission-pg" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"rawSql": "SELECT CASE WHEN status_code >= 200 AND status_code < 300 THEN 'success' ELSE 'error' END AS metric, count(*) AS value FROM invocations WHERE started_at BETWEEN $__timeFrom() AND $__timeTo() AND ('$namespace' = '$__all' OR namespace = ANY(string_to_array('$namespace', ','))) GROUP BY 1",
|
||||||
|
"format": "table",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "Топ функций по вызовам",
|
||||||
|
"type": "bargauge",
|
||||||
|
"gridPos": { "x": 0, "y": 8, "w": 12, "h": 8 },
|
||||||
|
"datasource": { "type": "postgres", "uid": "fission-pg" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"rawSql": "SELECT function_name AS metric, count(*) AS value FROM invocations WHERE started_at BETWEEN $__timeFrom() AND $__timeTo() AND ('$namespace' = '$__all' OR namespace = ANY(string_to_array('$namespace', ','))) GROUP BY 1 ORDER BY 2 DESC LIMIT 10",
|
||||||
|
"format": "table",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"title": "Средняя латентность (ms)",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": { "x": 12, "y": 8, "w": 12, "h": 8 },
|
||||||
|
"datasource": { "type": "postgres", "uid": "fission-pg" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"rawSql": "SELECT date_trunc('hour', started_at) AS time, round(avg(duration_ms)) AS avg_ms, round(percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms)) AS p95_ms FROM invocations WHERE started_at BETWEEN $__timeFrom() AND $__timeTo() AND ('$namespace' = '$__all' OR namespace = ANY(string_to_array('$namespace', ','))) GROUP BY 1 ORDER BY 1",
|
||||||
|
"format": "time_series",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"title": "Последние вызовы",
|
||||||
|
"type": "table",
|
||||||
|
"gridPos": { "x": 0, "y": 16, "w": 24, "h": 8 },
|
||||||
|
"datasource": { "type": "postgres", "uid": "fission-pg" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"rawSql": "SELECT started_at AS time, namespace, function_name, trigger_type, event_type, status_code, duration_ms FROM invocations WHERE ('$namespace' = '$__all' OR namespace = ANY(string_to_array('$namespace', ','))) ORDER BY started_at DESC LIMIT 50",
|
||||||
|
"format": "table",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"schemaVersion": 38
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: grafana-dashboard-providers
|
||||||
|
namespace: grafana
|
||||||
|
data:
|
||||||
|
providers.yaml: |
|
||||||
|
apiVersion: 1
|
||||||
|
providers:
|
||||||
|
- name: fission
|
||||||
|
type: file
|
||||||
|
disableDeletion: false
|
||||||
|
editable: true
|
||||||
|
options:
|
||||||
|
path: /var/lib/grafana/dashboards
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: grafana-datasources
|
||||||
|
namespace: grafana
|
||||||
|
data:
|
||||||
|
postgres.yaml: |
|
||||||
|
apiVersion: 1
|
||||||
|
datasources:
|
||||||
|
- name: PostgreSQL
|
||||||
|
type: postgres
|
||||||
|
uid: fission-pg
|
||||||
|
url: postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local:5432
|
||||||
|
database: sqsdb
|
||||||
|
user: super
|
||||||
|
secureJsonData:
|
||||||
|
password: "BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif"
|
||||||
|
jsonData:
|
||||||
|
sslmode: disable
|
||||||
|
postgresVersion: 1700
|
||||||
|
timescaledb: false
|
||||||
|
editable: false
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: grafana
|
||||||
|
namespace: grafana
|
||||||
|
labels:
|
||||||
|
app: grafana
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: grafana
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: grafana
|
||||||
|
spec:
|
||||||
|
securityContext:
|
||||||
|
fsGroup: 472
|
||||||
|
runAsUser: 472
|
||||||
|
containers:
|
||||||
|
- name: grafana
|
||||||
|
image: grafana/grafana:11.6.1
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
ports:
|
||||||
|
- containerPort: 3000
|
||||||
|
env:
|
||||||
|
- name: GF_SECURITY_ADMIN_USER
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: grafana-admin
|
||||||
|
key: admin-user
|
||||||
|
- name: GF_SECURITY_ADMIN_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: grafana-admin
|
||||||
|
key: admin-password
|
||||||
|
- name: GF_SERVER_ROOT_URL
|
||||||
|
value: "https://fission.kube5s.ru/grafana"
|
||||||
|
- name: GF_SERVER_DOMAIN
|
||||||
|
value: "fission.kube5s.ru"
|
||||||
|
- name: GF_SERVER_SERVE_FROM_SUB_PATH
|
||||||
|
value: "true"
|
||||||
|
- name: GF_USERS_ALLOW_SIGN_UP
|
||||||
|
value: "false"
|
||||||
|
- name: GF_AUTH_ANONYMOUS_ENABLED
|
||||||
|
value: "false"
|
||||||
|
- name: GF_ORGS_AUTO_ASSIGN_ORG
|
||||||
|
value: "true"
|
||||||
|
- name: GF_ORGS_AUTO_ASSIGN_ORG_ID
|
||||||
|
value: "1"
|
||||||
|
- name: GF_ORGS_AUTO_ASSIGN_ORG_ROLE
|
||||||
|
value: "Viewer"
|
||||||
|
- name: GF_FEATURE_TOGGLES_ENABLE
|
||||||
|
value: "publicDashboards"
|
||||||
|
- name: GF_PATHS_PROVISIONING
|
||||||
|
value: "/etc/grafana/provisioning"
|
||||||
|
volumeMounts:
|
||||||
|
- name: storage
|
||||||
|
mountPath: /var/lib/grafana
|
||||||
|
- name: datasources
|
||||||
|
mountPath: /etc/grafana/provisioning/datasources
|
||||||
|
- name: dashboard-providers
|
||||||
|
mountPath: /etc/grafana/provisioning/dashboards
|
||||||
|
- name: dashboards
|
||||||
|
mountPath: /var/lib/grafana/dashboards
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 128Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 256Mi
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /grafana/api/health
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 30
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /grafana/api/health
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
volumes:
|
||||||
|
- name: storage
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: grafana-storage
|
||||||
|
- name: datasources
|
||||||
|
configMap:
|
||||||
|
name: grafana-datasources
|
||||||
|
- name: dashboard-providers
|
||||||
|
configMap:
|
||||||
|
name: grafana-dashboard-providers
|
||||||
|
- name: dashboards
|
||||||
|
configMap:
|
||||||
|
name: grafana-dashboard-fission
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: grafana-subpath
|
||||||
|
namespace: grafana
|
||||||
|
annotations:
|
||||||
|
# Без rewrite — Grafana сама обрабатывает /grafana/... через serve_from_sub_path
|
||||||
|
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx
|
||||||
|
rules:
|
||||||
|
- host: fission.kube5s.ru
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /grafana
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: grafana
|
||||||
|
port:
|
||||||
|
number: 3000
|
||||||
|
# TLS не нужен — управляется Ingress в namespace fission (fission-tls)
|
||||||
|
# При миграции на grafana.kube5s.ru:
|
||||||
|
# 1. Поменять GF_SERVER_ROOT_URL → https://grafana.kube5s.ru
|
||||||
|
# 2. Убрать GF_SERVER_SERVE_FROM_SUB_PATH (или оставить false)
|
||||||
|
# 3. Создать Ingress в namespace grafana с host grafana.kube5s.ru + TLS
|
||||||
|
# 4. Удалить этот файл
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: grafana
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: grafana-storage
|
||||||
|
namespace: grafana
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 2Gi
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: grafana-admin
|
||||||
|
namespace: grafana
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
admin-user: admin
|
||||||
|
admin-password: "GrafanaAdmin2026!"
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: grafana
|
||||||
|
namespace: grafana
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: grafana
|
||||||
|
ports:
|
||||||
|
- port: 3000
|
||||||
|
targetPort: 3000
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: grafana
|
||||||
|
namespace: grafana
|
||||||
|
annotations:
|
||||||
|
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||||
|
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- grafana.kube5s.ru
|
||||||
|
secretName: grafana-tls
|
||||||
|
rules:
|
||||||
|
- host: grafana.kube5s.ru
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: grafana
|
||||||
|
port:
|
||||||
|
number: 3000
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
# Руководство: контракт функций в Fission runtime
|
||||||
|
|
||||||
|
Дата: 2026-05-19
|
||||||
|
Версия консоли: v1.3.94+
|
||||||
|
Источник: дизассемблирование реальных env-образов в кластере + отладка тестов
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Содержание
|
||||||
|
|
||||||
|
1. [PHP](#php)
|
||||||
|
2. [Node.js](#nodejs)
|
||||||
|
3. [Python](#python)
|
||||||
|
4. [Ruby](#ruby)
|
||||||
|
5. [Найденные баги и исправления](#баги)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHP
|
||||||
|
|
||||||
|
### Образ
|
||||||
|
```
|
||||||
|
ghcr.io/fission/php-env:latest
|
||||||
|
```
|
||||||
|
Сервер: `ReactPHP` на порту `:8888`, файл `/app/server.php`.
|
||||||
|
|
||||||
|
### Entrypoint
|
||||||
|
```
|
||||||
|
main.php::handler
|
||||||
|
```
|
||||||
|
Формат: `filename::functionName`. Двойное двоеточие — разделитель файла и функции.
|
||||||
|
|
||||||
|
### ⚠️ ГЛАВНЫЙ БАГ (2026-05-19)
|
||||||
|
|
||||||
|
**`return ['statusCode' => 200, 'body' => ...]` — PHP env ИГНОРИРУЕТ return value функции.**
|
||||||
|
|
||||||
|
Почему: server.php создаёт `$response = new Response()` (пустой PSR-7 объект),
|
||||||
|
вызывает функцию и возвращает этот же объект без чтения результата вызова:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// server.php — реальный код PHP env:
|
||||||
|
if (function_exists($userFunction)) {
|
||||||
|
$response = new Response();
|
||||||
|
ob_end_clean();
|
||||||
|
$userFunction(['request' =>$request, 'response' => $response, 'logger' => $logger]);
|
||||||
|
return $response; // ← возвращает оригинальный объект, не результат вызова
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Правильный формат — PSR-7
|
||||||
|
|
||||||
|
Функция должна **писать** в `$ctx["response"]->getBody()`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
function handler($ctx) {
|
||||||
|
$response = $ctx["response"];
|
||||||
|
$response->getBody()->write(json_encode([
|
||||||
|
'status' => 'ok',
|
||||||
|
'value' => 42,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Доступные ключи контекста:
|
||||||
|
- `$ctx["request"]` — `Psr\Http\Message\ServerRequestInterface`
|
||||||
|
- `$ctx["response"]` — `Psr\Http\Message\ResponseInterface` (RingCentral\Psr7)
|
||||||
|
- `$ctx["logger"]` — `Monolog\Logger`
|
||||||
|
|
||||||
|
### Backwards compatibility (echo)
|
||||||
|
|
||||||
|
Если функция `handler` НЕ объявлена, PHP env перехватывает весь вывод через `ob_start()` и возвращает его как тело ответа:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
// Работает, но нет доступа к request/response/logger
|
||||||
|
echo json_encode(['key' => 'value']);
|
||||||
|
```
|
||||||
|
|
||||||
|
Использовать только для самых простых случаев без работы с запросом.
|
||||||
|
|
||||||
|
### Примеры
|
||||||
|
|
||||||
|
**Минимальный (PSR-7):**
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
function handler($ctx) {
|
||||||
|
$ctx["response"]->getBody()->write("Hello from PHP");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**С JSON-ответом:**
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
function handler($ctx) {
|
||||||
|
$result = ['sum' => 1 + 2, 'lang' => 'php'];
|
||||||
|
$ctx["response"]->getBody()->write(json_encode($result));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**С чтением тела запроса:**
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
function handler($ctx) {
|
||||||
|
$body = json_decode((string)$ctx["request"]->getBody(), true);
|
||||||
|
$name = $body['name'] ?? 'World';
|
||||||
|
$ctx["response"]->getBody()->write("Hello, $name!");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**CPU-нагрузка:**
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
function fib($n) {
|
||||||
|
if ($n < 2) return $n;
|
||||||
|
return fib($n-1) + fib($n-2);
|
||||||
|
}
|
||||||
|
function handler($ctx) {
|
||||||
|
$ctx["response"]->getBody()->write('result:' . fib(28));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Что НЕ работает
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ❌ НЕПРАВИЛЬНО — return игнорируется
|
||||||
|
function handler(array $ctx): array {
|
||||||
|
return ['statusCode' => 200, 'body' => 'hello'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ НЕПРАВИЛЬНО — echo не работает если функция объявлена
|
||||||
|
function handler($ctx) {
|
||||||
|
echo "hello"; // ob_start уже закрыт перед вызовом
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Node.js
|
||||||
|
|
||||||
|
### Образ
|
||||||
|
```
|
||||||
|
ghcr.io/fission/node-env:latest
|
||||||
|
```
|
||||||
|
Сервер: Express на порту `:8888`, файл `/usr/src/app/server.js`.
|
||||||
|
|
||||||
|
### Entrypoint
|
||||||
|
```
|
||||||
|
main
|
||||||
|
```
|
||||||
|
**Без расширения `.js`.**
|
||||||
|
|
||||||
|
### ⚠️ ГЛАВНЫЙ БАГ (2026-05-19)
|
||||||
|
|
||||||
|
**`functionName = "main.js"` ломает специализацию.**
|
||||||
|
|
||||||
|
Почему: server.js делает `split(".")` для любого functionName содержащего точку:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// server.js — реальный код node-env:
|
||||||
|
if (req.body.functionName && req.body.functionName.includes('.')) {
|
||||||
|
// ПРЕДПОЛАГАЕТСЯ формат: 'file.function'
|
||||||
|
const entrypoint = req.body.functionName.split(".");
|
||||||
|
filename = entrypoint[0]; // "main"
|
||||||
|
funcname = entrypoint[1]; // "js" ← ищет экспорт с именем "js"!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`"main.js"` → `filename="main"`, `funcname="js"` → после загрузки модуля ищет `module["js"]` → undefined → specialize 500.
|
||||||
|
|
||||||
|
**Правильно:** `functionName = "main"` → `funcname = undefined` → берёт `default export` = `module.exports`.
|
||||||
|
|
||||||
|
### Формат кода
|
||||||
|
|
||||||
|
Консоль оборачивает пользовательский код в CJS wrapper. Пользователь пишет:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Простая функция, экспортирует async handler:
|
||||||
|
module.exports = async function(ctx) {
|
||||||
|
return { status: 200, body: "Hello from Node.js" };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Или через именованный экспорт:
|
||||||
|
```js
|
||||||
|
async function handler(ctx) {
|
||||||
|
return { status: 200, body: "ok" };
|
||||||
|
}
|
||||||
|
module.exports = handler;
|
||||||
|
```
|
||||||
|
|
||||||
|
Формат возвращаемого объекта:
|
||||||
|
```js
|
||||||
|
{ status: 200, body: "строка или JSON" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Примеры
|
||||||
|
|
||||||
|
**Минимальный:**
|
||||||
|
```js
|
||||||
|
module.exports = async function(ctx) {
|
||||||
|
return { status: 200, body: "hello-node" };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**С JSON:**
|
||||||
|
```js
|
||||||
|
module.exports = async function(ctx) {
|
||||||
|
const result = { lang: "nodejs", fib25: 75025 };
|
||||||
|
return { status: 200, body: JSON.stringify(result) };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**CPU-нагрузка:**
|
||||||
|
```js
|
||||||
|
function fib(n) { return n < 2 ? n : fib(n-1) + fib(n-2); }
|
||||||
|
module.exports = async function(ctx) {
|
||||||
|
return { status: 200, body: "fib35:" + fib(35) };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Python
|
||||||
|
|
||||||
|
### Образ
|
||||||
|
```
|
||||||
|
naeel/fission-python-env:v1.1
|
||||||
|
```
|
||||||
|
(НЕ использовать официальный `ghcr.io/fission/python-env` — не поддерживает `def main(event, context)`)
|
||||||
|
|
||||||
|
### Entrypoint
|
||||||
|
```
|
||||||
|
main.main
|
||||||
|
```
|
||||||
|
Формат: `module.function`.
|
||||||
|
|
||||||
|
### Формат функции
|
||||||
|
|
||||||
|
```python
|
||||||
|
def main(event, context):
|
||||||
|
return {
|
||||||
|
"status": 200,
|
||||||
|
"body": "Hello from Python"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Примеры
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json
|
||||||
|
|
||||||
|
def main(event, context):
|
||||||
|
result = {"lang": "python", "sum": sum(range(100))}
|
||||||
|
return {"status": 200, "body": json.dumps(result)}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ruby
|
||||||
|
|
||||||
|
### Образ
|
||||||
|
```
|
||||||
|
ghcr.io/fission/ruby-env:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Entrypoint
|
||||||
|
```
|
||||||
|
handler
|
||||||
|
```
|
||||||
|
Имя метода в загруженном файле.
|
||||||
|
|
||||||
|
### Формат функции
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
def handler(event, context)
|
||||||
|
{ statusCode: 200, body: "Hello from Ruby" }
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Примеры
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
require 'json'
|
||||||
|
|
||||||
|
def handler(event, context)
|
||||||
|
result = { lang: "ruby", value: (1..10).reduce(:+) }
|
||||||
|
{ statusCode: 200, body: result.to_json }
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Баги
|
||||||
|
|
||||||
|
### БАГ A — ExpiryReaper race condition (исправлен в v1.3.93)
|
||||||
|
|
||||||
|
**Симптом:** PHP/последний язык в цикле CREATE теряет Package — удаляется ExpiryReaper'ом.
|
||||||
|
|
||||||
|
**Причина:** `activeFunctions` snapshot снимается ДО создания Function. Reaper проверяет orphan packages по устаревшему снимку → последний Package = orphan → удалён.
|
||||||
|
|
||||||
|
**Коммит:** `72e5d50`
|
||||||
|
**Файл:** `console/internal/cloud/tenant.go`
|
||||||
|
**Fix:** свежий LIST functions (`freshFunctions`) перед orphan-check вместо кешированного снимка.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### БАГ B — Node.js entrypoint `"main.js"` ломает specializeV2 (исправлен в v1.3.94)
|
||||||
|
|
||||||
|
**Симптом:** Node.js функция не специализируется, invoke timeout.
|
||||||
|
|
||||||
|
**Причина:** node-env `specializeV2` делает `split(".")` по любой точке в functionName. `"main.js"` → `funcname="js"` → ищет `module["js"]` → undefined → specialize 500 → бесконечный retry.
|
||||||
|
|
||||||
|
**Коммит:** `090a7af`
|
||||||
|
**Файл:** `console/internal/runtime/entrypoint.go`
|
||||||
|
**Fix:** entrypoint Node.js = `"main"` (без расширения). funcname=undefined → берёт `default export`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### БАГ C — PHP `return array` игнорируется (исправлен в test_heavy.sh, v1.3.94)
|
||||||
|
|
||||||
|
**Симптом:** PHP invoke возвращает `status=200` но `response_raw=""`.
|
||||||
|
|
||||||
|
**Причина:** PHP env создаёт пустой `$response` объект, вызывает функцию, возвращает оригинальный объект — return value функции не читается.
|
||||||
|
|
||||||
|
**Коммит:** `042b1e5`
|
||||||
|
**Файл:** `scripts/test_heavy.sh`
|
||||||
|
**Fix:** PHP код в тестах переписан на PSR-7: `$ctx["response"]->getBody()->write(...)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сводная таблица: правильный контракт
|
||||||
|
|
||||||
|
| Язык | Entrypoint | Формат функции | return/write |
|
||||||
|
|--------|---------------------|-------------------------------------------------------|---------------------------|
|
||||||
|
| Python | `main.main` | `def main(event, context):` | `return {"status":200,...}`|
|
||||||
|
| Node.js| `main` | `module.exports = async function(ctx) {...}` | `return { status, body }` |
|
||||||
|
| PHP | `main.php::handler` | `function handler($ctx) {...}` | `$ctx["response"]->getBody()->write(...)` |
|
||||||
|
| Ruby | `handler` | `def handler(event, context)` | `{ statusCode: 200, body: ... }` |
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Bench Results — PV Storage — 2026-05-20
|
||||||
|
|
||||||
|
**RUNID:** 9250986
|
||||||
|
**Дата:** Wed May 20 07:23:06–07:27:07 MSK 2026
|
||||||
|
**Storage:** PVC (PV)
|
||||||
|
**API:** https://fission.kube5s.ru/console/api
|
||||||
|
**SUB:** bench@bench.local
|
||||||
|
**Итог: PASS=20 FAIL=0 TOTAL=20**
|
||||||
|
|
||||||
|
## Таблица результатов
|
||||||
|
|
||||||
|
| LANG | SIZE | UPLOAD(ms) | COLD(ms) | HOT_AVG(ms) | STATUS |
|
||||||
|
|--------|---------|-----------|---------|------------|--------|
|
||||||
|
| python | tiny | 308 | 4384 | 771 | OK |
|
||||||
|
| python | small | 161 | 1947 | 1860 | OK |
|
||||||
|
| python | medium | 2751 | 1387 | 1183 | OK |
|
||||||
|
| python | large | 2764 | 1382 | 1184 | OK |
|
||||||
|
| nodejs | tiny | 3150 | 8011 | 1175 | OK |
|
||||||
|
| nodejs | small | 2764 | 1526 | 1198 | OK |
|
||||||
|
| nodejs | medium | 2745 | 2597 | 1185 | OK |
|
||||||
|
| nodejs | large | 2737 | 2603 | 1314 | OK |
|
||||||
|
| ruby | tiny | 3148 | 2105 | 1209 | OK |
|
||||||
|
| ruby | small | 2768 | 1339 | 1194 | OK |
|
||||||
|
| ruby | medium | 2759 | **81549** | 1183 | OK (аномалия cold, 3-й прогон подряд) |
|
||||||
|
| ruby | large | 2743 | 1390 | 787 | OK |
|
||||||
|
| php | tiny | 1547 | 2546 | 94 | OK |
|
||||||
|
| php | small | 904 | 1106 | 1213 | OK |
|
||||||
|
| php | medium | 2744 | 1571 | 1198 | OK ✓ FIXED |
|
||||||
|
| php | large | 2732 | 1508 | 1151 | OK ✓ FIXED |
|
||||||
|
| python | archive | 2759 | 1392 | 1178 | OK |
|
||||||
|
| nodejs | archive | 3177 | 1545 | 1197 | OK |
|
||||||
|
| ruby | archive | 2780 | 1376 | 1187 | OK |
|
||||||
|
| php | archive | 2785 | 1416 | 1172 | OK |
|
||||||
|
|
||||||
|
## Что было исправлено
|
||||||
|
|
||||||
|
PHP medium/large — заменены arrow functions `fn()=>` на традиционный синтаксис `function(){}`.
|
||||||
|
PHP env в кластере < 7.4, arrow functions добавлены в PHP 7.4.
|
||||||
|
|
||||||
|
Лог на VM: `/home/naeel/terra/fission/test-results/bench-func/bench_full_2026-05-20_07-23-06.log`
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Bench Results — S3 Storage (ngcloud msk-1) — 2026-05-20
|
||||||
|
|
||||||
|
**RUNID:** 9251970
|
||||||
|
**Дата:** Wed May 20 07:39:30–07:43:27 MSK 2026
|
||||||
|
**Storage:** S3 (`sless-functions`, `s3.msk-1.ngcloud.ru`, region `msk-1`)
|
||||||
|
**API:** https://fission.kube5s.ru/console/api
|
||||||
|
**SUB:** bench@bench.local
|
||||||
|
**Итог: PASS=20 FAIL=0 TOTAL=20**
|
||||||
|
|
||||||
|
## Таблица результатов S3
|
||||||
|
|
||||||
|
| LANG | SIZE | UPLOAD(ms) | COLD(ms) | HOT_AVG(ms) | STATUS |
|
||||||
|
|--------|---------|-----------|---------|------------|--------|
|
||||||
|
| python | tiny | 3115 | 2453 | 621 | OK |
|
||||||
|
| python | small | 1360 | 889 | 481 | OK |
|
||||||
|
| python | medium | 1370 | 982 | 446 | OK |
|
||||||
|
| python | large | 2561 | 1471 | 1157 | OK |
|
||||||
|
| nodejs | tiny | 3348 | 8347 | 1210 | OK |
|
||||||
|
| nodejs | small | 2907 | 1528 | 1270 | OK |
|
||||||
|
| nodejs | medium | 2949 | 1576 | 1187 | OK |
|
||||||
|
| nodejs | large | 2747 | 2705 | 1146 | OK |
|
||||||
|
| ruby | tiny | 3145 | 2164 | 1190 | OK |
|
||||||
|
| ruby | small | 2959 | 1886 | 1151 | OK |
|
||||||
|
| ruby | medium | 2756 | **80568** | 120 | OK (аномалия, 4-й прогон подряд) |
|
||||||
|
| ruby | large | 1386 | 1652 | 1168 | OK |
|
||||||
|
| php | tiny | 3158 | 1845 | 1156 | OK |
|
||||||
|
| php | small | 2751 | 1565 | 1218 | OK |
|
||||||
|
| php | medium | 2676 | 1516 | 1143 | OK |
|
||||||
|
| php | large | 3146 | 1581 | 1190 | OK |
|
||||||
|
| python | archive | 2752 | 1463 | 1160 | OK |
|
||||||
|
| nodejs | archive | 3191 | 1832 | 1166 | OK |
|
||||||
|
| ruby | archive | 2771 | 1454 | 1297 | OK |
|
||||||
|
| php | archive | 2768 | 1589 | 1188 | OK |
|
||||||
|
|
||||||
|
Лог на VM: `/home/naeel/terra/fission/test-results/bench-func/bench_full_2026-05-20_07-39-30.log`
|
||||||
|
|
||||||
|
## Сравнение PV vs S3
|
||||||
|
|
||||||
|
PV RUNID=9250986 (2026-05-20 07:23) vs S3 RUNID=9251970 (2026-05-20 07:39).
|
||||||
|
|
||||||
|
| LANG | SIZE | UPLOAD PV | UPLOAD S3 | COLD PV | COLD S3 | HOT PV | HOT S3 |
|
||||||
|
|--------|---------|-----------|-----------|---------|---------|--------|--------|
|
||||||
|
| python | tiny | 308 | 3115 | 4384 | 2453 | 771 | 621 |
|
||||||
|
| python | small | 161 | 1360 | 1947 | 889 | 1860 | 481 |
|
||||||
|
| python | medium | 2751 | 1370 | 1387 | 982 | 1183 | 446 |
|
||||||
|
| python | large | 2764 | 2561 | 1382 | 1471 | 1184 | 1157 |
|
||||||
|
| nodejs | tiny | 3150 | 3348 | 8011 | 8347 | 1175 | 1210 |
|
||||||
|
| nodejs | small | 2764 | 2907 | 1526 | 1528 | 1198 | 1270 |
|
||||||
|
| nodejs | medium | 2745 | 2949 | 2597 | 1576 | 1185 | 1187 |
|
||||||
|
| nodejs | large | 2737 | 2747 | 2603 | 2705 | 1314 | 1146 |
|
||||||
|
| ruby | tiny | 3148 | 3145 | 2105 | 2164 | 1209 | 1190 |
|
||||||
|
| ruby | small | 2768 | 2959 | 1339 | 1886 | 1194 | 1151 |
|
||||||
|
| ruby | medium | 2759 | 2756 | 81549 | 80568 | 1183 | 120 |
|
||||||
|
| ruby | large | 2743 | 1386 | 1390 | 1652 | 787 | 1168 |
|
||||||
|
| php | tiny | 1547 | 3158 | 2546 | 1845 | 94 | 1156 |
|
||||||
|
| php | small | 904 | 2751 | 1106 | 1565 | 1213 | 1218 |
|
||||||
|
| php | medium | 2744 | 2676 | 1571 | 1516 | 1198 | 1143 |
|
||||||
|
| php | large | 2732 | 3146 | 1508 | 1581 | 1314 | 1190 |
|
||||||
|
| python | archive | 2759 | 2752 | 1392 | 1463 | 1178 | 1160 |
|
||||||
|
| nodejs | archive | 3177 | 3191 | 1545 | 1832 | 1197 | 1166 |
|
||||||
|
| ruby | archive | 2780 | 2771 | 1376 | 1454 | 1297 | 1297 |
|
||||||
|
| php | archive | 2785 | 2768 | 1416 | 1589 | 1172 | 1188 |
|
||||||
|
|
||||||
|
## Выводы
|
||||||
|
|
||||||
|
### Upload (загрузка архива в storagesvc)
|
||||||
|
- Мелкие файлы (tiny/small): S3 **медленнее** — python/tiny: 308→3115ms (+2807ms), python/small: 161→1360ms (+1199ms)
|
||||||
|
- Средние/крупные файлы: сопоставимо (±200ms)
|
||||||
|
- Причина: HTTP round-trip до S3 ngcloud vs локальный PVC (vcd-disk-ext4 тоже сетевой, но внутри кластера)
|
||||||
|
|
||||||
|
### Cold start (первый invoke)
|
||||||
|
- Python: S3 **быстрее** — small: 1947→889ms, medium: 1387→982ms
|
||||||
|
- Nodejs medium: S3 быстрее — 2597→1576ms
|
||||||
|
- PHP: сопоставимо (±50ms)
|
||||||
|
- Ruby small: S3 медленнее — 1339→1886ms
|
||||||
|
- Итог: cold start на S3 ≈ на PV, разброс в пределах погрешности кластера
|
||||||
|
|
||||||
|
### Hot avg (последующие invoke)
|
||||||
|
- Практически одинаково на обоих бэкендах (±100ms)
|
||||||
|
- Фетчер кэширует архив в pod — хранилище не влияет на hot path
|
||||||
|
|
||||||
|
### ruby/medium аномалия
|
||||||
|
- PV: 81549ms → S3: 80568ms — воспроизводится **4-й раз подряд** на разных бэкендах
|
||||||
|
- Подтверждает: аномалия не связана с типом хранилища, это поведение executor/poolmgr
|
||||||
|
|
||||||
|
### Вердикт
|
||||||
|
**S3 и PV практически эквивалентны по производительности** при нагрузке нашего масштаба.
|
||||||
|
Upload мелких файлов чуть медленнее на S3, cold start — сопоставим.
|
||||||
|
S3 предпочтительнее из-за простоты операций (нет PVC, нет проблем с PV provisioner).
|
||||||
+131
@@ -1,5 +1,136 @@
|
|||||||
# Progress Log
|
# Progress Log
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-05-19 (вечер) — Node.js require fix (v1.3.95) + суровые тесты
|
||||||
|
|
||||||
|
### Ветка: `feat/console-pv-storage` (git commit `fe8870a`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Исправленный баг: Node.js `require` недоступен внутри функции
|
||||||
|
|
||||||
|
**Симптом:** Любая Node.js функция, использующая `require(...)`, падала с:
|
||||||
|
```
|
||||||
|
ERR_MODULE_NOT_FOUND: Cannot find module '/userfunc/deployarchive/main'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Реальная причина:** В `console/internal/runtime/nodejs.go` wrapper генерировался как:
|
||||||
|
```js
|
||||||
|
(new Function('module', 'exports', code))(__mod, __mod.exports);
|
||||||
|
```
|
||||||
|
`new Function` выполняется в изолированном глобальном scope — `require` там не существует.
|
||||||
|
Вызов `require('crypto')` внутри кода → `ReferenceError` → ESM fallback → `import('file:///userfunc/deployarchive/main')` без `.js` → ERR_MODULE_NOT_FOUND.
|
||||||
|
|
||||||
|
**Fix** (`console/internal/runtime/nodejs.go`):
|
||||||
|
```js
|
||||||
|
// было:
|
||||||
|
(new Function('module', 'exports', code))(__mod, __mod.exports);
|
||||||
|
// стало:
|
||||||
|
(new Function('module', 'exports', 'require', code))(__mod, __mod.exports, require);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Также исправлено** (`console/internal/api/package.go`):
|
||||||
|
Regex `nodeJSWrapperPattern` обновлён — принимает оба формата (старый без `require` и новый с `require`), backward compatible.
|
||||||
|
|
||||||
|
**Unit-тест** (`console/internal/runtime/nodejs_test.go`):
|
||||||
|
Убраны устаревшие ESM-проверки, добавлены актуальные CJS-проверки.
|
||||||
|
|
||||||
|
**Образ:** `naeel/fission-console:v1.3.95`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Прогоны `test_heavy.sh`
|
||||||
|
|
||||||
|
Два прогона подряд с теми же условиями (namespace `fission-c3511430abe5675b`):
|
||||||
|
|
||||||
|
| Секция | af2f674f | be04cc1b | Вывод |
|
||||||
|
|--------|---------|---------|-------|
|
||||||
|
| S1 CREATE (4 яз) | PHP 502 → FAIL | **4/4 PASS** | PHP 502 в первом — transient |
|
||||||
|
| S1 invoke "не стабилизировался" | FAIL×4 | FAIL×4 | баг теста — WAIT_SEC < cold start |
|
||||||
|
| S2 invoke "не стабилизировался" | FAIL×4 | FAIL×4 | то же |
|
||||||
|
| S3 BIG RESPONSE size=46/48 | FAIL×2 | FAIL×2 | баг теста — проверяет поле `raw` (truncated), не реальный ответ |
|
||||||
|
| S4 invoke "не стабилизировался" | FAIL×4 | FAIL×4 | баг теста — WAIT_SEC < cold start |
|
||||||
|
| S5 RAPID 12/12 | PASS | PASS | ✓ |
|
||||||
|
| S6 Python PARALLEL 5/5 | PASS | PASS | ✓ |
|
||||||
|
| S6 Node.js PARALLEL 5/5 | **2/5 FAIL** | **2/5 FAIL** | pool size=1 не хватает на 5 параллельных — стабильно |
|
||||||
|
| S6 Ruby PARALLEL 5/5 | PASS | PASS | ✓ |
|
||||||
|
| S6 PHP PARALLEL 5/5 | 1/5 FAIL | **PASS** | первый — transient |
|
||||||
|
| S7 Python SLOW(6s) | PASS | PASS | ✓ |
|
||||||
|
| S7 Node.js SLOW(6s) | PASS (TRY=2) | PASS (TRY=1) | ✓ require fix работает |
|
||||||
|
| S8 Python MEMORY(20MB) | PASS | PASS | ✓ |
|
||||||
|
| S8 Node.js MEMORY(20MB) | PASS | PASS | ✓ |
|
||||||
|
| **ИТОГ** | **PASS=29 FAIL=17** | **PASS=31 FAIL=15** | |
|
||||||
|
|
||||||
|
Логи: `test-results/2026-05-19_*` (два файла)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Анализ стабильных FAIL=15 (оба прогона)
|
||||||
|
|
||||||
|
#### 1. S1+S2+S4 "не стабилизировался" (12 FAIL)
|
||||||
|
**Причина:** `WAIT_SEC` в `test_heavy.sh` меньше реального cold start.
|
||||||
|
Функции реально работают — проверено прямыми SSH-запросами через `sleep + curl`.
|
||||||
|
**Что нужно:** увеличить `WAIT_SEC` или добавить retry-loop с адекватным таймаутом.
|
||||||
|
|
||||||
|
#### 2. S3 BIG RESPONSE (2 FAIL)
|
||||||
|
**Причина:** тест проверяет `response_raw length` — это длина JSON-поля `raw` из ответа консоли.
|
||||||
|
Консоль обрезает `raw` до ~46-48 символов. Реальный ответ функции = 50KB (status=200 подтверждено).
|
||||||
|
**Что нужно:** либо фиксить тест (проверять Content-Length/реальный body), либо фиксить консоль (не обрезать `raw`).
|
||||||
|
|
||||||
|
#### 3. S6 Node.js PARALLEL 2/5 (1 FAIL)
|
||||||
|
**Причина:** pool size=1 (один pod). При 5 параллельных запросах 2 таймаутятся пока ждут специализацию нового пода.
|
||||||
|
Python/Ruby справляются 5/5 — у них специализация быстрее.
|
||||||
|
**Что нужно:** либо увеличить pool size в Node.js environment, либо уменьшить параллельность в тесте до 3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Что делать дальше с тестами
|
||||||
|
|
||||||
|
- [ ] Исправить `test_heavy.sh` S1/S2/S4: увеличить `WAIT_SEC` до 120-180s или добавить retry-poll
|
||||||
|
- [ ] Исправить `test_heavy.sh` S3: проверять реальный ответ, не поле `raw`
|
||||||
|
- [ ] Решить S6 Node.js PARALLEL: увеличить pool или снизить параллельность
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-05-19 — PV Storage Migration + Fetcher v1.23.0
|
||||||
|
|
||||||
|
### Ветка: `feat/console-pv-storage` (git commit `2aadae3`, pushed)
|
||||||
|
|
||||||
|
**Задача:** Перевести storagesvc с S3 на PV (PersistentVolume), привести все образы к v1.23.0.
|
||||||
|
|
||||||
|
#### Что сделано
|
||||||
|
|
||||||
|
- `helm/fission-values.yaml` — `imageTag: v1.23.0`, `fetcher.image: naeel/fetcher`, `fetcher.imageTag: v1.23.0`, PV backend включён
|
||||||
|
- `naeel/fetcher:v1.23.0` — собран из `fission-src/cmd/fetcher/`, запушен в Docker Hub (отдельный бинарник, в `naeel/fission-bundle` не входит)
|
||||||
|
- `fission-storage-pvc` — Bound, 2Gi, `vcd-disk-ext4`, namespace `fission`; S3 полностью отключён
|
||||||
|
|
||||||
|
#### Баги найдены и задокументированы (`doc/report-2026-05-19-pv-migration-bugs.md`)
|
||||||
|
|
||||||
|
| # | Описание | Причина | Fix |
|
||||||
|
|---|----------|---------|-----|
|
||||||
|
| 1 | Helm upgrade откатил образы с v1.23.0 на v1.22.1 | `imageTag: v1.22.1` в values | Исправлено на v1.23.0 |
|
||||||
|
| 2 | Field manager конфликт при helm upgrade | fission-console патчит FISSION_RESOURCE_NAMESPACES | Перед upgrade: `kubectl delete deploy executor router -n fission` |
|
||||||
|
| 3 | Pool pods ImagePullBackOff — `FETCHER_IMAGE=fission/fetcher:v1.22.0` (не существует) | fetcher — отдельный образ, его нет в fission-bundle | Собрали `naeel/fetcher:v1.23.0` |
|
||||||
|
| 4 | Router JWT password сменился после helm upgrade | Новый secret | Console читает динамически — работает; CLI токены устарели |
|
||||||
|
|
||||||
|
#### Тест-прогон `run_all.sh` — PASS=8 FAIL=0 (319 сек)
|
||||||
|
|
||||||
|
| Скрипт | Результат | Кол-во проверок |
|
||||||
|
|--------|-----------|----------------|
|
||||||
|
| test_auth.sh | ✅ PASS | 14 |
|
||||||
|
| test_functions_crud.sh | ✅ PASS | 29 |
|
||||||
|
| test_ttl.sh | ✅ PASS | 10 |
|
||||||
|
| test_isolation.sh | ✅ PASS | 11 |
|
||||||
|
| test_update.sh | ✅ PASS | 10 |
|
||||||
|
| test_invoke.sh | ✅ PASS | 9 |
|
||||||
|
| test_linters.sh | ✅ PASS | 31 |
|
||||||
|
| test_stress.sh | ✅ PASS | 3 (8 параллельных пользователей) |
|
||||||
|
|
||||||
|
Лог: `test-results/2026-05-19_08-51-31.log`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## TODO (backlog)
|
## TODO (backlog)
|
||||||
|
|
||||||
### Биллинг / статистика для бухгалтерии
|
### Биллинг / статистика для бухгалтерии
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Отчёт: миграция S3→PV + баги helm upgrade — 2026-05-19
|
||||||
|
|
||||||
|
## Что делали
|
||||||
|
|
||||||
|
1. Бенчмарк 3 хранилищ (local-path PV, vcd-disk-ext4 PV, S3)
|
||||||
|
2. Переехали storagesvc с S3 на PV (vcd-disk-ext4, 2Gi)
|
||||||
|
3. Обнаружили что S3 фактически не использовался (все Package URL — http://storagesvc..., не s3://)
|
||||||
|
4. Миграция была no-op: перенесено=0, пропущено=44
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Баги и ошибки
|
||||||
|
|
||||||
|
### БАГ 1: helm upgrade откатил executor/router с v1.23.0 → v1.22.1
|
||||||
|
|
||||||
|
**Причина:** в `helm/fission-values.yaml` был прописан `imageTag: v1.22.1` вместо `v1.23.0`.
|
||||||
|
До helm upgrade executor/router жили вне helm (задеплоены через `kubectl set image`).
|
||||||
|
|
||||||
|
**Симптом:** консоль не показывала функции.
|
||||||
|
|
||||||
|
**Исправление:** изменён `imageTag: v1.23.0` в values, пересоздан executor/router.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### БАГ 2: field manager конфликт — fission-console vs helm
|
||||||
|
|
||||||
|
**Причина:** fission-console патчит `FISSION_RESOURCE_NAMESPACES` в executor и router через server-side apply.
|
||||||
|
Когда helm пытается обновить те же deployment-ы — конфликт field manager.
|
||||||
|
|
||||||
|
**Симптом:**
|
||||||
|
```
|
||||||
|
conflict with "fission-console" using apps/v1:
|
||||||
|
.spec.template.spec.containers[name="executor"].env[name="FISSION_RESOURCE_NAMESPACES"].value
|
||||||
|
```
|
||||||
|
|
||||||
|
**Обход:** перед `helm upgrade` удалять executor и router вручную:
|
||||||
|
```bash
|
||||||
|
kubectl delete deploy executor router -n fission
|
||||||
|
helm upgrade fission fission-charts/fission-all --version 1.22.1 \
|
||||||
|
-n fission -f helm/fission-values.yaml --no-hooks --timeout 180s
|
||||||
|
```
|
||||||
|
|
||||||
|
**Системное решение:** нужно чтобы fission-console не трогал deployments executor/router напрямую (FISSION_RESOURCE_NAMESPACES должен читаться из ConfigMap или CRD, а не инжектироваться в deployment env).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### БАГ 3: FETCHER_IMAGE = fission/fetcher:v1.22.0 — образ не существует
|
||||||
|
|
||||||
|
**Причина:** helm chart `fission-all:1.22.1` имеет дефолтное значение:
|
||||||
|
```yaml
|
||||||
|
fetcher:
|
||||||
|
image: fission/fetcher
|
||||||
|
imageTag: v1.22.0
|
||||||
|
```
|
||||||
|
Этот образ на Docker Hub не существует (fission переехал на ghcr.io).
|
||||||
|
|
||||||
|
До наших helm upgrade executor не управлялся helm — FETCHER_IMAGE либо не был задан (код падал на дефолт `ghcr.io/fission/fetcher`), либо был задан правильно вручную.
|
||||||
|
|
||||||
|
После helm upgrade — chart прописал неправильный FETCHER_IMAGE.
|
||||||
|
|
||||||
|
**Симптом:**
|
||||||
|
- Pool pods: `ImagePullBackOff` на контейнере `fetcher`
|
||||||
|
- `fission/fetcher:v1.22.0: not found` на Docker Hub
|
||||||
|
- Функции не запускаются (504 при invoke)
|
||||||
|
|
||||||
|
**Правильное значение:** `naeel/fission-bundle:v1.23.0`
|
||||||
|
Fetcher — часть fission-bundle, отдельного образа нет.
|
||||||
|
Находится в: `fission-src/pkg/fetcher/`
|
||||||
|
|
||||||
|
**Неправильные ключи в values (игнорируются чартом):**
|
||||||
|
```yaml
|
||||||
|
fetcherImage: naeel/fission-bundle # ← НЕПРАВИЛЬНО, такого ключа нет
|
||||||
|
fetcherImageTag: v1.23.0 # ← НЕПРАВИЛЬНО
|
||||||
|
```
|
||||||
|
|
||||||
|
**Правильные ключи:**
|
||||||
|
```yaml
|
||||||
|
fetcher:
|
||||||
|
image: naeel/fission-bundle # ← ПРАВИЛЬНО
|
||||||
|
imageTag: v1.23.0 # ← ПРАВИЛЬНО
|
||||||
|
```
|
||||||
|
|
||||||
|
Шаблон чарта (`charts/fission-all/templates/_helpers.tpl`):
|
||||||
|
```
|
||||||
|
{{- define "fetcherImage" -}}
|
||||||
|
{{- $args := list (.Values.fetcher.repository | default .Values.repository) .Values.fetcher.image .Values.fetcher.imageTag -}}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### БАГ 4: router JWT пароль сменился при helm upgrade
|
||||||
|
|
||||||
|
**Причина:** helm upgrade пересоздал secret `router` с новым паролем.
|
||||||
|
|
||||||
|
| До | После |
|
||||||
|
|----|-------|
|
||||||
|
| `slxGj3G3FAD7l5ms2tJ9` | `58oKMuBIvmBBXHXqaLHw` |
|
||||||
|
|
||||||
|
fission-console читает пароль из secret динамически (`valueFrom: secretKeyRef`) — поэтому сам работает.
|
||||||
|
CLI-инструменты с сохранённым токеном требуют перегенерации токена.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Текущее состояние (на момент отчёта)
|
||||||
|
|
||||||
|
| Компонент | Образ | Статус |
|
||||||
|
|-----------|-------|--------|
|
||||||
|
| executor, router, buildermgr, storagesvc, timer, webhook, kubewatcher | `naeel/fission-bundle:v1.23.0` | ✅ Running |
|
||||||
|
| fission-console | `naeel/fission-console:v1.3.92` | ✅ Running |
|
||||||
|
| FETCHER_IMAGE (env в executor) | `fission/fetcher:v1.22.0` | ❌ НЕПРАВИЛЬНО |
|
||||||
|
| fission-storage-pvc | 2Gi, vcd-disk-ext4, Bound | ✅ OK |
|
||||||
|
|
||||||
|
## Что нужно сделать
|
||||||
|
|
||||||
|
1. Исправить `helm/fission-values.yaml` — заменить `fetcherImage`/`fetcherImageTag` на правильную секцию `fetcher:`
|
||||||
|
2. Сделать helm upgrade (с предварительным удалением executor/router)
|
||||||
|
3. Проверить что pool pods поднимаются без ImagePullBackOff
|
||||||
|
4. Проверить что функции nubes1 (`a1`, `c1`) запускаются
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# Анализ бенчмарка bench_full.sh — 2026-05-20
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Запущен полный бенчмарк `bench-func/bench_full.sh` (RUNID=9248232).
|
||||||
|
Namespace: `fission-08f114ea3595cccf` (sub: bench@bench.local).
|
||||||
|
4 языка × 4 размера (tiny/small/medium/large) + 4 архивных теста = 20 итого.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Результаты bench_full.sh
|
||||||
|
|
||||||
|
| LANG | SIZE | UPLOAD(ms) | COLD(ms) | HOT_AVG(ms) | STATUS |
|
||||||
|
|--------|---------|-----------|---------|------------|----------------|
|
||||||
|
| python | tiny | 2529 | 3317 | 1198 | OK |
|
||||||
|
| python | small | 2764 | 1387 | 1182 | OK |
|
||||||
|
| python | medium | 2762 | 1374 | 1185 | OK |
|
||||||
|
| python | large | 2769 | 1366 | 1653 | OK |
|
||||||
|
| nodejs | tiny | 3158 | 9410 | 1172 | OK (аномалия*) |
|
||||||
|
| nodejs | small | 2751 | 1559 | 1193 | OK |
|
||||||
|
| nodejs | medium | 2754 | 2471 | 1158 | OK |
|
||||||
|
| nodejs | large | 2745 | 1583 | 1190 | OK |
|
||||||
|
| ruby | tiny | 3142 | 3255 | 1162 | OK |
|
||||||
|
| ruby | small | 2737 | 1402 | 1181 | OK |
|
||||||
|
| ruby | medium | 3151 | **80451** | 115 | OK (аномалия!) |
|
||||||
|
| ruby | large | 1116 | 2448 | 104 | OK |
|
||||||
|
| php | tiny | 1137 | 2528 | 1200 | OK |
|
||||||
|
| php | small | 2747 | 1469 | 1158 | OK |
|
||||||
|
| php | medium | 2749 | — | — | **COLD-TIMEOUT** |
|
||||||
|
| php | large | 2725 | — | — | **COLD-TIMEOUT** |
|
||||||
|
| python | archive | 2749 | 1403 | 1176 | OK |
|
||||||
|
| nodejs | archive | 2777 | 1539 | 535 | OK |
|
||||||
|
| ruby | archive | 1367 | 821 | 787 | OK |
|
||||||
|
| php | archive | 2744 | 1575 | 1189 | OK |
|
||||||
|
|
||||||
|
**ИТОГ: PASS=18, FAIL=2**
|
||||||
|
|
||||||
|
*nodejs/tiny cold=9410ms — одиночный выброс, не воспроизводился.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Аномалия: ruby/medium cold=80451ms
|
||||||
|
|
||||||
|
### Анализ
|
||||||
|
Проведено ×6 независимых прогонов (diag_bugs.sh, два запуска):
|
||||||
|
- 3780 / 1402 / 1384 / 3225 / 1435 / 1773 ms
|
||||||
|
|
||||||
|
**Вывод:** не воспроизводится. Разовый выброс (вероятно node pressure или transient network).
|
||||||
|
**Статус: ЗАКРЫТ. Не баг.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Баг: PHP medium + large COLD-TIMEOUT
|
||||||
|
|
||||||
|
### Первичная гипотеза (верна)
|
||||||
|
`bench_full.sh` генерирует PHP код для medium/large с arrow functions (PHP 7.4+):
|
||||||
|
```php
|
||||||
|
// medium
|
||||||
|
array_map(fn($s) => strrev(strtoupper($s)), $words)
|
||||||
|
array_map(fn($x) => ($x - $m) ** 2, $data)
|
||||||
|
|
||||||
|
// large
|
||||||
|
array_map(fn($i) => array_map(fn($j) => ..., ...), ...)
|
||||||
|
```
|
||||||
|
PHP env на кластере — версия **< 7.4**. Arrow syntax → Parse error → pod возвращает 500.
|
||||||
|
|
||||||
|
PHP tiny/small и PHP archive — используют традиционный синтаксис без `fn()=>` → OK.
|
||||||
|
|
||||||
|
### Механизм pool exhaustion (важно!)
|
||||||
|
|
||||||
|
При invoke сломанной функции:
|
||||||
|
1. Executor берёт pool pod → специализирует под функцию
|
||||||
|
2. PHP парсер: Parse error → возвращает 500
|
||||||
|
3. Executor считает это ошибкой вызова → ретраит следующий pod
|
||||||
|
4. 20 ретраев × 5s = 100s → COLD TIMEOUT
|
||||||
|
5. За это время пул (`console-php-env`) в namespace исчерпан
|
||||||
|
|
||||||
|
**Следствие:** php/large идёт ПОСЛЕ php/medium. К моменту его запуска пул уже разрушен → php/large тоже COLD TIMEOUT даже если бы его код был правильным.
|
||||||
|
|
||||||
|
### Диагностика diag_bugs.sh — что мы делали
|
||||||
|
|
||||||
|
Создан скрипт `bench-func/diag_bugs.sh` с 6 тестами:
|
||||||
|
1. `php-arrow-min` — минимальный PHP с `fn($x)=>$x*2`
|
||||||
|
2. `php-version` — только `return PHP_VERSION` (стандартный PHP)
|
||||||
|
3. `php-med-noarr` — medium логика через foreach вместо arrow
|
||||||
|
4. `ruby-med-1/2/3` — ruby/medium ×3 независимых функции
|
||||||
|
|
||||||
|
### Ошибки в процессе диагностики
|
||||||
|
|
||||||
|
**Ошибка 1: SIGPIPE при `| tee`**
|
||||||
|
Первый прогон завершился досрочно — скрипт через SSH pipe убивался SIGPIPE при прокрутке/чтении терминала. Исправлено: nohup + прямая запись в файл.
|
||||||
|
|
||||||
|
**Ошибка 2: 5 параллельных прогонов diag_bugs.sh**
|
||||||
|
В процессе диагностики случайно накопилось 5+ одновременных процессов `diag_bugs.sh` на VM (PIDs: 2476997, 2478955, 2480445, 2482045, 2482452). Все конкурировали за PHP pool → дополнительный pool exhaustion.
|
||||||
|
|
||||||
|
**Следствие:** тесты `php-version` и `php-med-noarr` тоже дали COLD TIMEOUT — не из-за собственного бага, а из-за занятого пула.
|
||||||
|
|
||||||
|
### Что реально подтверждено vs что предполагается
|
||||||
|
|
||||||
|
| Факт | Статус |
|
||||||
|
|------|--------|
|
||||||
|
| `fn()=>` вызывает COLD TIMEOUT | **ПОДТВЕРЖДЕНО** (×2 прогона) |
|
||||||
|
| PHP env версия < 7.4 | **ПРЕДПОЛОЖЕНИЕ** (php-version не дал ответа из-за pool exhaustion) |
|
||||||
|
| php-version FAIL = самостоятельный баг | **ОПРОВЕРГНУТО** — следствие pool exhaustion |
|
||||||
|
| php-med-noarr FAIL = PHP env broken | **НЕ ПОДТВЕРЖДЕНО** — тест не прошёл чисто |
|
||||||
|
|
||||||
|
### Что НЕ удалось выяснить
|
||||||
|
- Точная версия PHP в env образе
|
||||||
|
- Работает ли `php-med-noarr` (medium без arrow) в изолированном тесте
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Механизм pool exhaustion — общая проблема
|
||||||
|
|
||||||
|
Одна функция с синтаксической ошибкой может "заморозить" весь PHP пул namespace:
|
||||||
|
1. Сломанная функция → 20 ретраев × пул pod
|
||||||
|
2. Пул исчерпан → **все остальные PHP функции того же пользователя** получают COLD TIMEOUT
|
||||||
|
3. Пользователь видит что "рабочая" функция сломалась — не понимает почему
|
||||||
|
|
||||||
|
В реальном проде это критично: один неосторожный деплой = все PHP функции пользователя не отвечают.
|
||||||
|
|
||||||
|
### Почему pod "держится"
|
||||||
|
Pool pod ≠ выполнение кода. Pod — контейнер-заготовка. При invoke:
|
||||||
|
- Executor специализирует pod (загружает код через /specialize)
|
||||||
|
- PHP парсер падает → 500 → executor ретраит СЛЕДУЮЩИЙ pod
|
||||||
|
- Каждый ретрай = новый pod из пула
|
||||||
|
|
||||||
|
Pod не "держится" в одном — executor расходует пул по одному pod'у за ретрай.
|
||||||
|
|
||||||
|
### Системные решения (не реализованы)
|
||||||
|
1. **Circuit breaker в executor** — после N провалов подряд для одной функции → вернуть ошибку, не трогать пул
|
||||||
|
2. **Синтаксическая валидация при upload** — `php -l`, `python3 -m py_compile`, `ruby -c`, `node --check` — сломанный код не попадает в систему
|
||||||
|
3. **Pool size limit per namespace** — ограничить max pods на env × namespace
|
||||||
|
|
||||||
|
Вариант 2 (валидация при upload) — реализуем в нашем коде (fission-console), остальные — в fission-bundle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Предложения — что делать
|
||||||
|
|
||||||
|
### Приоритет 1 (быстро): исправить bench_full.sh
|
||||||
|
Заменить arrow functions в PHP medium/large на традиционный синтаксис.
|
||||||
|
- `fn($s) => strrev(strtoupper($s))` → `function($s){ return strrev(strtoupper($s)); }`
|
||||||
|
- `fn($x) => ($x - $m) ** 2` → foreach цикл
|
||||||
|
- Аналогично для large (вложенные array_map)
|
||||||
|
|
||||||
|
После фикса запустить bench_full.sh заново — должно быть PASS=20.
|
||||||
|
|
||||||
|
### Приоритет 2 (чистая диагностика): проверить php-med-noarr
|
||||||
|
Убить фоновые процессы на VM, запустить одиночный тест php-version/php-med-noarr в изоляции — убедиться что PHP env работает для стандартного кода.
|
||||||
|
|
||||||
|
### Приоритет 3 (системный): валидация кода при upload
|
||||||
|
Добавить в fission-console синтаксическую проверку кода перед созданием функции. Защита от pool exhaustion для всех пользователей.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файлы
|
||||||
|
|
||||||
|
- `bench-func/bench_full.sh` — основной бенчмарк (требует фикс PHP medium/large)
|
||||||
|
- `bench-func/diag_bugs.sh` — диагностический скрипт (создан в этой сессии)
|
||||||
|
- `test-results/bench-func/` — логи прогонов
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
# Анализ репозитория fission-console
|
||||||
|
|
||||||
|
Составлен: 2026-05-21. Основан только на чтении исходного кода + истории git.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
**Web UI + API proxy** для управления Fission (Kubernetes-native serverless).
|
||||||
|
Написан на Go. Работает внутри K8s-кластера. Пользователи аутентифицируются
|
||||||
|
через Deck API (облачный SSO).
|
||||||
|
|
||||||
|
## Архитектура — поток данных
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser (SPA) → Console API → K8s API (CRD — dynamic client)
|
||||||
|
→ Fission Router (invoke proxy — /fission-function)
|
||||||
|
→ storagesvc (архивы → S3 ngcloud msk-1, бакет sless-functions)
|
||||||
|
→ Deck API (auth — prod/dev/test)
|
||||||
|
→ PostgreSQL (billing invocations)
|
||||||
|
→ Grafana (дашборды — по одному Org на namespace)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Пакеты
|
||||||
|
|
||||||
|
| Пакет | Роль |
|
||||||
|
|---|---|
|
||||||
|
| `cmd/server/` | Точка входа: парсинг env vars, создание зависимостей, запуск HTTP |
|
||||||
|
| `internal/api/` | **Ядро**: все HTTP-хендлеры (29 файлов) |
|
||||||
|
| `internal/auth/` | Аутентификация: Deck API + Demo (test fallback) |
|
||||||
|
| `internal/cloud/` | Namespace lifecycle: квата, NetworkPolicy, одноразовый создаватель |
|
||||||
|
| `internal/fission/` | Константы GVR + SetupFissionNamespace + EnsureEnvironment |
|
||||||
|
| `internal/model/` | Общие типы: CreateFunctionRequest, LangEnvMap |
|
||||||
|
| `internal/runtime/` | Упаковка кода: zip, ESM-обёртка NodeJS, python/phh/ruby/go |
|
||||||
|
| `internal/billing/` | Статистика: PostgreSQL (NoopStore если BILLING_DSN пуст) |
|
||||||
|
| `internal/stats/` | Grafana: Organization/dashboard per namespace |
|
||||||
|
| `ui/` | Embed SPA (index.html), отдельный /cron/ UI |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Фичи — детальный разбор
|
||||||
|
|
||||||
|
### 1. Аутентификация
|
||||||
|
|
||||||
|
**Файлы:** `auth.go`, `middleware.go`, `handlers.go` (строка handleAuth)
|
||||||
|
|
||||||
|
- `POST /console/api/auth` — тело `{token, env}`. Валидация через Deck API.
|
||||||
|
Возвращает `{ok, env, namespace, email}`.
|
||||||
|
- **Middleware** — каждый приватный роут → `Authenticator.Authenticate(token, env)`.
|
||||||
|
Без токена → 401.
|
||||||
|
- **Namespace** = `"fission-"` + hex(SHA256(sub)[:8]). Для test-пользователей:
|
||||||
|
`"fission-test-"` + hex(SHA256(sub)[:8]).
|
||||||
|
- **DeckAuthenticator** — HTTP-запрос к `deck-api-{env}.ngcloud.ru/api/v1/user`.
|
||||||
|
Кеш в `sync.Map` (ключ = `env:token`, значение = expiry time). Таймаут 5s.
|
||||||
|
- **DemoAuthenticator** — для тестовых стендов: sub = email = token, без проверки.
|
||||||
|
- **MultiAuthenticator** — последовательно пробует JWT → Demo.
|
||||||
|
|
||||||
|
### 2. Управление Namespace
|
||||||
|
|
||||||
|
**Файлы:** `cloud/tenant.go`, `cloud/quota.go`, `cloud/network.go`
|
||||||
|
|
||||||
|
- **EnsureUserNS** — единая точка входа. Singleflight (параллельные запросы
|
||||||
|
для одного NS ждут на chan). Семафор: не более 3 одновременных созданий.
|
||||||
|
In-memory кэш уже созданных NS.
|
||||||
|
- **Что создаётся при первом запросе пользователя:**
|
||||||
|
- Namespace с лейблами `managed-by=fission-console`, `fission.io/managed=true`
|
||||||
|
- ServiceAccounts: `fission-fetcher`, `fission-builder`
|
||||||
|
- RoleBindings: для executor, router, buildermgr, kubewatcher, timer, fetcher, builder
|
||||||
|
- ResourceQuota (из env vars, по умолчанию: CPU req 1/lim 12, mem 2Gi/6Gi, pods 30, functions 20)
|
||||||
|
- LimitRange (дефолтный CPU 500m, mem 256Mi на контейнер)
|
||||||
|
- NetworkPolicy `deny-cross-tenant` — ingress только из своего NS, fission NS, kube-system
|
||||||
|
- **ExpiryReaper** — горутина в main, раз в 5 минут (REAPER_INTERVAL). Удаляет
|
||||||
|
функции с аннотацией `expires_at` в прошлом.
|
||||||
|
- **NSReconciler** — сверяет FISSION_RESOURCE_NAMESPACES.
|
||||||
|
|
||||||
|
### 3. CRUD функций
|
||||||
|
|
||||||
|
**Файлы:** `function_code.go`, `function_archive.go`, `function_crud.go`,
|
||||||
|
`function_clone.go`, `storagesvc.go`
|
||||||
|
|
||||||
|
#### Создание из кода (POST /console/api/functions, JSON)
|
||||||
|
|
||||||
|
- Тело: `{name, language, code, deps?, entrypoint?, route?, methods?, timeout?, ttl?}`
|
||||||
|
- **Валидация:** имя `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`, ≤57 символов, код ≤1MB.
|
||||||
|
- **Языки:** python, nodejs, php, ruby, go.
|
||||||
|
- **Порядок:** Package CRD → Function CRD → HTTPTrigger CRD. При ошибке откат.
|
||||||
|
- **Типы пакетов:**
|
||||||
|
- Python: zip с кодом + опционально requirements.txt (если deps не пуст)
|
||||||
|
- NodeJS: ESM-wrapped zip (package.json + main.js с `__invoke` dispatcher)
|
||||||
|
- PHP: zip с `main.php` + опционально composer.json
|
||||||
|
- Ruby: zip с `handler.rb` + опционально Gemfile
|
||||||
|
- Go: source package (builder компилирует .so плагин)
|
||||||
|
- **Загрузка:** через storagesvc → S3 (ngcloud msk-1, бакет `sless-functions`).
|
||||||
|
Fallback на type:literal если storagesvcURL пуст.
|
||||||
|
- **TTL:** парсит `1h`, `30m`, `7d`, `24h`. Ставит аннотацию `expires_at` на
|
||||||
|
Function CRD в `metadata.annotations`.
|
||||||
|
- **Env vars:** сохраняются в аннотации `fission-console/env-vars` (JSON).
|
||||||
|
|
||||||
|
#### Создание из архива (POST /console/api/functions, multipart/form-data)
|
||||||
|
|
||||||
|
- Поле формы: `archive` (zip-файл).
|
||||||
|
- Проверка magic bytes: `PK` (0x50 0x4B).
|
||||||
|
- Размер ≤32MB.
|
||||||
|
- Загружается в storagesvc как есть, без модификации.
|
||||||
|
- Аннотация `fission-console/source-type: "archive"`.
|
||||||
|
- Поля формы: `name, language/environment, entrypoint, route, methods, timeout, ttl`.
|
||||||
|
|
||||||
|
#### GET функции (GET /console/api/functions/:name)
|
||||||
|
|
||||||
|
- Возвращает: name, namespace, environment, package, entrypoint, timeout,
|
||||||
|
created_at, updated_at, code, deps, source_type, archive_filename, route,
|
||||||
|
methods, env_vars, raw CRD.
|
||||||
|
- **Извлечение кода:** пробует source.literal → deployment.literal → URL
|
||||||
|
(через storagesvc download для type:url). Для NodeJS распознаёт и анрапает
|
||||||
|
ESM-обёртку (`decodeArchiveBytesToSource` в `package.go`).
|
||||||
|
|
||||||
|
#### Обновление кода (PUT /console/api/functions/:name/code)
|
||||||
|
|
||||||
|
- Тело: `{code, deps?, timeout?}`
|
||||||
|
- Создаёт **новый Package** (новое имя = cache miss в executor). Старый удаляется.
|
||||||
|
- Та же сборка архива что и при создании (buildDeployArchive).
|
||||||
|
|
||||||
|
#### Обновление архива (PUT /console/api/functions/:name/archive)
|
||||||
|
|
||||||
|
- multipart/form-data, поле `archive`. Загружается в storagesvc, новый Package.
|
||||||
|
|
||||||
|
#### Клонирование (POST /console/api/functions/:name/clone)
|
||||||
|
|
||||||
|
- Тело: `{new_name, route?}`
|
||||||
|
- Для type:literal — копирует base64 literal в новый Package.
|
||||||
|
- Для type:url — скачивает архив из storagesvc → заливает новый → новый Package.
|
||||||
|
- Создаёт новый Function + HTTPTrigger. Для Go копирует source-пакет.
|
||||||
|
- При ошибке откатывает созданные объекты.
|
||||||
|
|
||||||
|
#### DELETE (DELETE /console/api/functions/:name)
|
||||||
|
|
||||||
|
- Каскадное: удаляет HTTPTrigger → Package CRD → удаляет архив из storagesvc
|
||||||
|
(DELETE /v1/archive?id=... через storagesvc).
|
||||||
|
- Проверяет что функция реально удалена из K8s.
|
||||||
|
|
||||||
|
#### Timeout, Logs, EnvVars
|
||||||
|
|
||||||
|
- `PUT /functions/:name/timeout` — патчит только `spec.functionTimeout`
|
||||||
|
(без замены кода/архива).
|
||||||
|
- `GET /functions/:name/logs` — читает логи пода функции через K8s API
|
||||||
|
(rest client, `pod/log`).
|
||||||
|
- `GET /functions/:name/envvars` / `PUT /functions/:name/envvars` — аннотация
|
||||||
|
`fission-console/env-vars`.
|
||||||
|
|
||||||
|
### 4. Invoke (вызов функций)
|
||||||
|
|
||||||
|
**Файл:** `function_invoke.go`
|
||||||
|
|
||||||
|
- **`POST /console/api/functions/:name/invoke`** — для UI. Проксирует через
|
||||||
|
Fission Router. URL: `routerURL/fission-function/<ns>/<name>`.
|
||||||
|
Ответ: `{status, body, headers, cold_start, response_raw, pod, duration_ms}`.
|
||||||
|
Записывает в billing.
|
||||||
|
- **`/fn/<route>`** — публичный gateway. Строит URL по маршруту, проксирует
|
||||||
|
тело/заголовки (кроме служебных). Таймаут из `spec.functionTimeout`.
|
||||||
|
- **`/fission-function/<ns>/<name>`** — внутренний gateway для cron/timer/webhook.
|
||||||
|
- JWT-токен router: ServiceAccount token из `/var/run/secrets/...` (или SA_TOKEN_PATH),
|
||||||
|
кешируется на 12ч в `sync.Mutex`.
|
||||||
|
|
||||||
|
### 5. Триггеры
|
||||||
|
|
||||||
|
**Файлы:** `timetriggers.go`, `mqtriggers.go`, `kwtriggers.go`
|
||||||
|
|
||||||
|
- **HTTP Triggers** — создаются автоматически при создании функции.
|
||||||
|
Чтение списка: GET `/console/api/httptriggers`.
|
||||||
|
- **Time Triggers** — CRUD через `/console/api/timetriggers`.
|
||||||
|
Поля: `name, functionName, cron, method, subpath`. Создаются как
|
||||||
|
`fission.io/v1 TimeTrigger` CRD.
|
||||||
|
- **MQ Triggers** — `/console/api/mqtriggers`. Поля: `name, functionName,
|
||||||
|
queue, sqsEndpoint, accessKey, secretKey`. Создаёт Deployment (sqs-consumer)
|
||||||
|
+ Secret с credentials в NS пользователя. sqs-consumer поллит SQS →
|
||||||
|
вызывает функцию через JWT-authenticated router.
|
||||||
|
- **Kubernetes Watch Triggers** — `/console/api/kwtriggers`. Поля:
|
||||||
|
`name, functionName, resourceType, namespace, labelSelector`.
|
||||||
|
|
||||||
|
### 6. AI/Lint фичи
|
||||||
|
|
||||||
|
**Файлы:** `ai_check.go`, `lint_archive.go`, `explain_archive.go`, `ai_prompts.go`
|
||||||
|
|
||||||
|
- **`POST /ai/check`** — синтаксическая проверка кода. Реальные линтеры
|
||||||
|
(не LLM): `node --check`, `python3 -m py_compile`, `ruby -c`, `php -l`,
|
||||||
|
Go через `go/parser` прямо в процессе.
|
||||||
|
- **`POST /ai/lint-archive`** — принимает zip, линтит каждый файл с
|
||||||
|
известным расширением. Защита от zip bomb: max 100KB архив, max 100KB
|
||||||
|
распакованных. Опционально: проверка entrypoint и соответствия языка.
|
||||||
|
- **`POST /ai/explain-archive`** — извлекает код из архива (max 20KB),
|
||||||
|
отправляет в LLM через OpenRouter API с вопросом "что делает?".
|
||||||
|
- **`POST /ai/ask`** — chat/генерация кода через LLM.
|
||||||
|
4 режима codegen: генерация с нуля, объяснение, исправление ошибки,
|
||||||
|
универсальный. Плюс explain и chat режимы.
|
||||||
|
- **Prompts** (`ai_prompts.go`) — шаблоны для каждого языка с сигнатурами
|
||||||
|
функций (Python: `def main(event, context)`, NodeJS: `module.exports`,
|
||||||
|
PHP: `function handler(...)`, Ruby: `def handler(context)`,
|
||||||
|
Go: `func Handler(...)`).
|
||||||
|
|
||||||
|
### 7. Billing (статистика)
|
||||||
|
|
||||||
|
**Файлы:** `billing/billing.go`, `billing/pg.go`, `billing/noop.go`,
|
||||||
|
`billing/factory.go`
|
||||||
|
|
||||||
|
- **Интерфейс Store** — `RecordInvocation(Invocation)`. Асинхронный,
|
||||||
|
fire-and-forget.
|
||||||
|
- **pgStore** — PostgreSQL через pgxpool. Таблица `invocations`.
|
||||||
|
Пулы: max 4 conn, min 1. Каждая запись в своей горутине.
|
||||||
|
- **NoopStore** — если `BILLING_DSN` не задан или не удалось подключиться —
|
||||||
|
статистика молча отключена.
|
||||||
|
- **Поля Invocation:** Namespace, FunctionName, TriggerType (http/cron/console/event),
|
||||||
|
Route, HTTPMethod, StartedAt, DurationMS, StatusCode, ColdStart,
|
||||||
|
RequestBytes, ResponseBytes, ErrorMsg, RecordedBy ("console"/"router"),
|
||||||
|
EventType (create/delete/clone/update/invoke).
|
||||||
|
- **Где записывается:** handleInvokeFunction, handleInvokeRoute,
|
||||||
|
handleFissionFunctionGateway, handleCreateFunction, handleDeleteFunction,
|
||||||
|
handleUpdateFunctionCode, handleCloneFunction.
|
||||||
|
|
||||||
|
### 8. Stats / Grafana
|
||||||
|
|
||||||
|
**Файлы:** `stats/grafana.go`, `stats/factory.go`, `stats/noop.go`,
|
||||||
|
`stats/provider.go`
|
||||||
|
|
||||||
|
- **GrafanaProvider** — для каждого namespace создаёт Grafana Organization
|
||||||
|
(имя = namespace), PostgreSQL datasource (тот же DSN что billing,
|
||||||
|
uid="fission-user-pg"), hardcoded dashboard (SQL: WHERE namespace='...'),
|
||||||
|
public dashboard link (accessToken).
|
||||||
|
- **Кеш:** `sync.RWMutex` + per-namespace кеш orgID и accessToken.
|
||||||
|
Идемпотентно — если Org уже есть, повторно не создаётся.
|
||||||
|
- **NoopProvider** — если `GRAFANA_INTERNAL_URL` не задан.
|
||||||
|
- **Провизия:** вызывается в `handleAuth` (fire-and-forget горутина),
|
||||||
|
и в `handleStatsDashboard`.
|
||||||
|
|
||||||
|
### 9. UI
|
||||||
|
|
||||||
|
**Файлы:** `ui/embed.go`, `ui/cron.go`
|
||||||
|
|
||||||
|
- **Embedded SPA** — `//go:embed index.html`. Раздаётся на `/console/`.
|
||||||
|
- **Cron UI** — отдельный handler для `/cron/` (Cron метрики, статика).
|
||||||
|
- **CORS:** `Access-Control-Allow-Origin: *` (все операции защищены токеном,
|
||||||
|
не cookie).
|
||||||
|
- **Security headers:** nosniff, X-Frame-Options DENY, CSP (default-src 'self'),
|
||||||
|
Referrer-Policy strict-origin-when-cross-origin, Permissions-Policy (нет камере/микрофону).
|
||||||
|
|
||||||
|
### 10. Мониторинг
|
||||||
|
|
||||||
|
- `/health`, `/console/health` — `ok\n`
|
||||||
|
- `/console/api/ns/status` — статус namespace (существует, кол-во функций)
|
||||||
|
- `/console/api/ns/debug` — дебаг-инфо (NS, user, labels)
|
||||||
|
- `/console/api/stats/dashboard-url` — возвращает Grafana public dashboard URL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Эволюция storagesvc (история git)
|
||||||
|
|
||||||
|
- **До 19 мая 2026:** S3 (изначальная архитектура)
|
||||||
|
- **19 мая:** `3eb4f56 feat: PV storage backend` — перешли на PVC (временное решение)
|
||||||
|
- **20 мая:** `7539680 chore: switch storagesvc to S3 backend (ngcloud msk-1)` —
|
||||||
|
вернулись на S3. PV-конфиг сохранён в `helm/fission-values-pv.yaml` для быстрого
|
||||||
|
отката.
|
||||||
|
- **Текущее состояние (21 мая):** S3, бакет `sless-functions`, endpoint
|
||||||
|
`https://s3.msk-1.ngcloud.ru`. Ветка `feat/s3-replicated`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Чего нет
|
||||||
|
|
||||||
|
- **Нет аутентификации через cookie** — только токен в заголовке.
|
||||||
|
- **Нет rate limiting** — только ResourceQuota на уровне K8s.
|
||||||
|
- **Нет WebSocket** — только REST.
|
||||||
|
- **Нет аудит-лога** — billing таблица не immutable, можно переписать.
|
||||||
|
- **Нет swagger/OpenAPI** — документация только в doc/.
|
||||||
|
- **Нет gRPC** — только HTTP/JSON.
|
||||||
|
- **Нет метрик Prometheus** — только billing в PostgreSQL.
|
||||||
|
- **Нет CI/CD** — тесты запускаются вручную.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="126mm" height="91mm" viewBox="0 0 126 91" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<ellipse cx="63" cy="45.5" rx="63" ry="45.5" fill="#cccccc" stroke="#222222" stroke-width="1" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 210 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="210mm" height="297mm" viewBox="0 0 210 297" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<ellipse cx="105" cy="148.5" rx="63" ry="45.5" fill="#cccccc" stroke="#222222" stroke-width="1" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 214 B |
@@ -0,0 +1,105 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Fission storage benchmark: local-path PV / vcd-disk-ext4 PV / S3
|
||||||
|
# Каждый вызов = полный путь: curl → Fission Router → Executor → Pod → Storage
|
||||||
|
|
||||||
|
set -e
|
||||||
|
N=${1:-10} # число итераций, по умолчанию 10
|
||||||
|
OUTDIR="$(cd "$(dirname "$0")/../../test-results" 2>/dev/null && pwd || echo /tmp)"
|
||||||
|
OUTFILE="$OUTDIR/bench-storage-$(date +%Y-%m-%d_%H-%M-%S).txt"
|
||||||
|
|
||||||
|
mkdir -p "$OUTDIR"
|
||||||
|
|
||||||
|
# Port-forward router
|
||||||
|
kubectl port-forward svc/router 8889:80 -n fission &>/tmp/pf-bench.log &
|
||||||
|
PF_PID=$!
|
||||||
|
trap "kill $PF_PID 2>/dev/null" EXIT
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
ROUTER="http://localhost:8889"
|
||||||
|
|
||||||
|
# JWT auth
|
||||||
|
PASSWORD=$(kubectl get secret router -n fission -o jsonpath="{.data.password}" | base64 -d)
|
||||||
|
USERNAME=$(kubectl get secret router -n fission -o jsonpath="{.data.username}" | base64 -d)
|
||||||
|
TOKEN=$(curl -s -X POST "$ROUTER/auth/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD\"}" \
|
||||||
|
| python3 -c "import sys,json; print(json.load(sys.stdin)['accesstoken'])")
|
||||||
|
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
echo "ERROR: не удалось получить JWT токен" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
AUTH="-H \"Authorization: Bearer $TOKEN\""
|
||||||
|
|
||||||
|
# Функция запуска N итераций и сбора статистики
|
||||||
|
bench_endpoint() {
|
||||||
|
local LABEL="$1"
|
||||||
|
local URL="$2"
|
||||||
|
local times=()
|
||||||
|
local errors=0
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== $LABEL ==="
|
||||||
|
printf "%-5s %-12s %s\n" "iter" "ms" "response"
|
||||||
|
|
||||||
|
for i in $(seq 1 $N); do
|
||||||
|
START=$(date +%s%N)
|
||||||
|
RESP=$(curl -s --max-time 60 -H "Authorization: Bearer $TOKEN" "$URL" 2>/dev/null)
|
||||||
|
END=$(date +%s%N)
|
||||||
|
MS=$(( (END - START) / 1000000 ))
|
||||||
|
|
||||||
|
if echo "$RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d['status']=='ok'" 2>/dev/null; then
|
||||||
|
STATUS="ok"
|
||||||
|
INNER=$(echo "$RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); k=list(d.keys()); print(' '.join(f'{k}={d[k]}' for k in k if k not in ['status']))" 2>/dev/null)
|
||||||
|
else
|
||||||
|
STATUS="ERR"
|
||||||
|
INNER="$RESP"
|
||||||
|
(( errors++ )) || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
times+=($MS)
|
||||||
|
printf "%-5s %-12s %s\n" "$i" "${MS}ms" "$STATUS $INNER"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Статистика
|
||||||
|
local sum=0 min=999999999 max=0
|
||||||
|
for t in "${times[@]}"; do
|
||||||
|
sum=$((sum + t))
|
||||||
|
[ $t -lt $min ] && min=$t
|
||||||
|
[ $t -gt $max ] && max=$t
|
||||||
|
done
|
||||||
|
local avg=$((sum / N))
|
||||||
|
local ok=$((N - errors))
|
||||||
|
|
||||||
|
echo "---"
|
||||||
|
printf " Успешно: %d/%d | min=%dms avg=%dms max=%dms\n" "$ok" "$N" "$min" "$avg" "$max"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Заголовок отчёта
|
||||||
|
{
|
||||||
|
echo "============================================================"
|
||||||
|
echo " Fission Storage Benchmark"
|
||||||
|
echo " Дата: $(date)"
|
||||||
|
echo " Итераций: $N на endpoint"
|
||||||
|
echo " Стек: curl → Router → Executor → Pod → Storage"
|
||||||
|
echo "============================================================"
|
||||||
|
|
||||||
|
bench_endpoint "local-path PV (100Mi, rawfile CSI, WaitForFirstConsumer)" \
|
||||||
|
"$ROUTER/check/local"
|
||||||
|
|
||||||
|
bench_endpoint "vcd-disk-ext4 PV (10Gi, VMware Cloud Director)" \
|
||||||
|
"$ROUTER/check/vcd"
|
||||||
|
|
||||||
|
bench_endpoint "S3 ngcloud (s3.msk-1.ngcloud.ru, bucket=sless-functions)" \
|
||||||
|
"$ROUTER/check/s3"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "============================================================"
|
||||||
|
echo " Завершено: $(date)"
|
||||||
|
echo "============================================================"
|
||||||
|
|
||||||
|
} | tee "$OUTFILE"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Результаты записаны: $OUTFILE"
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def main(event, context):
|
||||||
|
path = "/mnt/data/check.txt"
|
||||||
|
test_data = "storage-check-ok"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Write
|
||||||
|
t0 = time.time()
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write(test_data)
|
||||||
|
write_ms = round((time.time() - t0) * 1000, 2)
|
||||||
|
|
||||||
|
# Read
|
||||||
|
t0 = time.time()
|
||||||
|
with open(path, "r") as f:
|
||||||
|
result = f.read()
|
||||||
|
read_ms = round((time.time() - t0) * 1000, 2)
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
|
if result == test_data:
|
||||||
|
return {"status": "ok", "write_ms": write_ms, "read_ms": read_ms, "mount": path}
|
||||||
|
else:
|
||||||
|
return {"status": "error", "detail": "data mismatch"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "detail": str(e)}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import time
|
||||||
|
import boto3
|
||||||
|
from botocore.client import Config
|
||||||
|
|
||||||
|
|
||||||
|
def main(event, context):
|
||||||
|
# Credentials mounted by Fission at /secrets/<namespace>/<secretname>/<key>
|
||||||
|
try:
|
||||||
|
with open("/secrets/default/bench-s3-secret/access-key") as f:
|
||||||
|
access_key = f.read().strip()
|
||||||
|
with open("/secrets/default/bench-s3-secret/secret-key") as f:
|
||||||
|
secret_key = f.read().strip()
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "detail": f"secret read: {e}"}
|
||||||
|
|
||||||
|
s3 = boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url="https://s3.msk-1.ngcloud.ru",
|
||||||
|
aws_access_key_id=access_key,
|
||||||
|
aws_secret_access_key=secret_key,
|
||||||
|
config=Config(signature_version="s3v4"),
|
||||||
|
)
|
||||||
|
|
||||||
|
bucket = "sless-functions"
|
||||||
|
key = "storage-check/check.txt"
|
||||||
|
test_data = b"storage-check-ok"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Write
|
||||||
|
t0 = time.time()
|
||||||
|
s3.put_object(Bucket=bucket, Key=key, Body=test_data)
|
||||||
|
write_ms = round((time.time() - t0) * 1000, 2)
|
||||||
|
|
||||||
|
# Read
|
||||||
|
t0 = time.time()
|
||||||
|
result = s3.get_object(Bucket=bucket, Key=key)["Body"].read()
|
||||||
|
read_ms = round((time.time() - t0) * 1000, 2)
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
s3.delete_object(Bucket=bucket, Key=key)
|
||||||
|
|
||||||
|
if result == test_data:
|
||||||
|
return {"status": "ok", "write_ms": write_ms, "read_ms": read_ms, "bucket": bucket}
|
||||||
|
else:
|
||||||
|
return {"status": "error", "detail": "data mismatch"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "detail": str(e)}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
NS=default
|
||||||
|
|
||||||
|
echo "=== Removing HTTP triggers ==="
|
||||||
|
fission httptrigger delete --name check-local-t --namespace $NS 2>/dev/null || true
|
||||||
|
fission httptrigger delete --name check-vcd-t --namespace $NS 2>/dev/null || true
|
||||||
|
fission httptrigger delete --name check-s3-t --namespace $NS 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "=== Removing functions ==="
|
||||||
|
fission fn delete --name check-local --namespace $NS 2>/dev/null || true
|
||||||
|
fission fn delete --name check-vcd --namespace $NS 2>/dev/null || true
|
||||||
|
fission fn delete --name check-s3 --namespace $NS 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "=== Removing environment ==="
|
||||||
|
fission env delete --name bench-py --namespace $NS 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "=== Removing PVCs ==="
|
||||||
|
kubectl delete pvc storage-check-local -n $NS 2>/dev/null || true
|
||||||
|
kubectl delete pvc storage-check-vcd -n $NS 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "=== Removing secret ==="
|
||||||
|
kubectl delete secret bench-s3-secret -n $NS 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "Done."
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
NS=default
|
||||||
|
S3_KEY=0GLQRD38H4I6RBDB0EWJ
|
||||||
|
S3_SECRET=eTFibiHmBd96IApj9PYsboTR6OBoD7osxoarHykw
|
||||||
|
|
||||||
|
echo "=== PVCs ==="
|
||||||
|
kubectl apply -f pvc-local.yaml
|
||||||
|
kubectl apply -f pvc-vcd.yaml
|
||||||
|
|
||||||
|
echo "=== S3 Secret ==="
|
||||||
|
kubectl create secret generic bench-s3-secret \
|
||||||
|
--from-literal=access-key="$S3_KEY" \
|
||||||
|
--from-literal=secret-key="$S3_SECRET" \
|
||||||
|
-n $NS 2>/dev/null || echo "secret already exists"
|
||||||
|
|
||||||
|
echo "=== Environment (no resource limits to avoid quota) ==="
|
||||||
|
fission env create --name bench-py --image naeel/fission-python-env:v1.1 \
|
||||||
|
--mincpu 0 --maxcpu 0 --minmemory 0 --maxmemory 0 \
|
||||||
|
--poolsize 1 --namespace $NS 2>/dev/null || echo "env already exists"
|
||||||
|
|
||||||
|
echo "=== Functions ==="
|
||||||
|
# check-local и check-vcd используют newdeploy: 1 под = 1 PVC (RWO работает)
|
||||||
|
fission fn create --name check-local --env bench-py --code check_pv.py \
|
||||||
|
--executortype newdeploy --minscale 1 --maxscale 1 --namespace $NS 2>/dev/null || \
|
||||||
|
fission fn update --name check-local --code check_pv.py --namespace $NS
|
||||||
|
|
||||||
|
fission fn create --name check-vcd --env bench-py --code check_pv.py \
|
||||||
|
--executortype newdeploy --minscale 1 --maxscale 1 --namespace $NS 2>/dev/null || \
|
||||||
|
fission fn update --name check-vcd --code check_pv.py --namespace $NS
|
||||||
|
|
||||||
|
# check-s3 использует poolmgr
|
||||||
|
fission fn create --name check-s3 --env bench-py --code check_s3.py \
|
||||||
|
--namespace $NS --secret bench-s3-secret 2>/dev/null || \
|
||||||
|
fission fn update --name check-s3 --code check_s3.py --namespace $NS --secret bench-s3-secret
|
||||||
|
|
||||||
|
echo "=== Patching podspec (PVC mounts) ==="
|
||||||
|
kubectl patch function check-local -n $NS --type=merge -p "$(cat patch-local.json)"
|
||||||
|
kubectl patch function check-vcd -n $NS --type=merge -p "$(cat patch-vcd.json)"
|
||||||
|
|
||||||
|
echo "=== HTTP Triggers ==="
|
||||||
|
fission httptrigger create --name check-local-t --url /check/local --function check-local --method GET --namespace $NS 2>/dev/null || true
|
||||||
|
fission httptrigger create --name check-vcd-t --url /check/vcd --function check-vcd --method GET --namespace $NS 2>/dev/null || true
|
||||||
|
fission httptrigger create --name check-s3-t --url /check/s3 --function check-s3 --method GET --namespace $NS 2>/dev/null || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done! Жди ~60s (newdeploy pods + PVC binding), затем: ./test.sh"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
apiVersion: fission.io/v1
|
||||||
|
kind: Function
|
||||||
|
metadata:
|
||||||
|
name: check-local
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
InvokeStrategy:
|
||||||
|
ExecutionStrategy:
|
||||||
|
ExecutorType: newdeploy
|
||||||
|
MinScale: 1
|
||||||
|
MaxScale: 1
|
||||||
|
StrategyType: execution
|
||||||
|
environment:
|
||||||
|
name: bench-py
|
||||||
|
namespace: default
|
||||||
|
package:
|
||||||
|
functionName: main.main
|
||||||
|
packageref:
|
||||||
|
name: check-local-7d087421-7802-4af1-b111-03d71fe738cd
|
||||||
|
namespace: default
|
||||||
|
podspec:
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: storage-check-local
|
||||||
|
containers:
|
||||||
|
- name: bench-py
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /mnt/data
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
apiVersion: fission.io/v1
|
||||||
|
kind: Function
|
||||||
|
metadata:
|
||||||
|
name: check-vcd
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
InvokeStrategy:
|
||||||
|
ExecutionStrategy:
|
||||||
|
ExecutorType: newdeploy
|
||||||
|
MinScale: 1
|
||||||
|
MaxScale: 1
|
||||||
|
StrategyType: execution
|
||||||
|
environment:
|
||||||
|
name: bench-py
|
||||||
|
namespace: default
|
||||||
|
package:
|
||||||
|
functionName: main.main
|
||||||
|
packageref:
|
||||||
|
name: check-vcd-6e6076b2-2207-49f3-a12d-83fb50633285
|
||||||
|
namespace: default
|
||||||
|
podspec:
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: storage-check-vcd
|
||||||
|
containers:
|
||||||
|
- name: bench-py
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /mnt/data
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"spec": {
|
||||||
|
"podspec": {
|
||||||
|
"volumes": [
|
||||||
|
{
|
||||||
|
"name": "data",
|
||||||
|
"persistentVolumeClaim": {
|
||||||
|
"claimName": "storage-check-local"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"containers": [
|
||||||
|
{
|
||||||
|
"name": "bench-py",
|
||||||
|
"volumeMounts": [
|
||||||
|
{
|
||||||
|
"name": "data",
|
||||||
|
"mountPath": "/mnt/data"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"spec": {
|
||||||
|
"podspec": {
|
||||||
|
"volumes": [
|
||||||
|
{
|
||||||
|
"name": "data",
|
||||||
|
"persistentVolumeClaim": {
|
||||||
|
"claimName": "storage-check-vcd"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"containers": [
|
||||||
|
{
|
||||||
|
"name": "bench-py",
|
||||||
|
"volumeMounts": [
|
||||||
|
{
|
||||||
|
"name": "data",
|
||||||
|
"mountPath": "/mnt/data"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: storage-check-local
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
storageClassName: local-path
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 100Mi
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: storage-check-vcd
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
storageClassName: vcd-disk-ext4
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 10Gi
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Port-forward router to localhost:8888
|
||||||
|
kubectl port-forward svc/router 8888:80 -n fission &>/tmp/pf.log &
|
||||||
|
PF_PID=$!
|
||||||
|
trap "kill $PF_PID 2>/dev/null" EXIT
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
ROUTER="localhost:8888"
|
||||||
|
echo "Router via port-forward: $ROUTER"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== [1/3] local-path ==="
|
||||||
|
curl -sf --max-time 30 "http://$ROUTER/check/local" | python3 -m json.tool || echo "ERROR: no response"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== [2/3] vcd-disk-ext4 ==="
|
||||||
|
curl -sf --max-time 30 "http://$ROUTER/check/vcd" | python3 -m json.tool || echo "ERROR: no response"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== [3/3] S3 (ngcloud) ==="
|
||||||
|
curl -sf --max-time 30 "http://$ROUTER/check/s3" | python3 -m json.tool || echo "ERROR: no response"
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
|
||||||
|
def main(event, context):
|
||||||
|
pg_dsn = os.environ.get(
|
||||||
|
"PG_DSN",
|
||||||
|
"postgresql://super:BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif"
|
||||||
|
"@postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local:5432"
|
||||||
|
"/sqsdb?sslmode=disable",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Извлекаем тело сообщения из SQS (POST от sqs-consumer)
|
||||||
|
# event — Flask Request object: используем .data (bytes) или .get_json()
|
||||||
|
try:
|
||||||
|
data = event.get_json(force=True, silent=False)
|
||||||
|
except Exception:
|
||||||
|
raw = getattr(event, "data", None) or getattr(event, "body", b"")
|
||||||
|
if isinstance(raw, (bytes, bytearray)):
|
||||||
|
raw = raw.decode("utf-8")
|
||||||
|
data = json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
conn = psycopg2.connect(pg_dsn)
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO weather_metrics
|
||||||
|
(city, country, temperature, feels_like, humidity,
|
||||||
|
pressure, wind_speed, description, recorded_at)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, to_timestamp(%s))
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
data["city"],
|
||||||
|
data["country"],
|
||||||
|
data["temperature"],
|
||||||
|
data["feels_like"],
|
||||||
|
data["humidity"],
|
||||||
|
data["pressure"],
|
||||||
|
data["wind_speed"],
|
||||||
|
data["description"],
|
||||||
|
data["owm_timestamp"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {"status": "ok", "city": data["city"], "temp": data["temperature"]}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
psycopg2-binary==2.9.9
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
import boto3
|
||||||
|
from botocore.config import Config
|
||||||
|
|
||||||
|
# Open-Meteo: бесплатный API без ключа, реальные данные.
|
||||||
|
# https://open-meteo.com/en/docs
|
||||||
|
CITIES = [
|
||||||
|
{"name": "Moscow", "country": "RU", "lat": 55.7558, "lon": 37.6173},
|
||||||
|
{"name": "London", "country": "GB", "lat": 51.5074, "lon": -0.1278},
|
||||||
|
{"name": "Paphos", "country": "CY", "lat": 34.7753, "lon": 32.4242},
|
||||||
|
{"name": "Ulyanovsk", "country": "RU", "lat": 54.3282, "lon": 48.3866},
|
||||||
|
{"name": "Santiago", "country": "CL", "lat": -33.4489, "lon": -70.6693},
|
||||||
|
]
|
||||||
|
|
||||||
|
# WMO weather code → описание
|
||||||
|
WMO_DESCRIPTIONS = {
|
||||||
|
0: "clear sky", 1: "mainly clear", 2: "partly cloudy", 3: "overcast",
|
||||||
|
45: "fog", 48: "icy fog", 51: "light drizzle", 53: "drizzle",
|
||||||
|
55: "heavy drizzle", 61: "light rain", 63: "rain", 65: "heavy rain",
|
||||||
|
71: "light snow", 73: "snow", 75: "heavy snow", 80: "rain showers",
|
||||||
|
81: "showers", 82: "violent showers", 95: "thunderstorm",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_city(city):
|
||||||
|
params = {
|
||||||
|
"latitude": city["lat"],
|
||||||
|
"longitude": city["lon"],
|
||||||
|
"current": "temperature_2m,apparent_temperature,relative_humidity_2m,surface_pressure,wind_speed_10m,weather_code",
|
||||||
|
"wind_speed_unit": "ms",
|
||||||
|
"timezone": "UTC",
|
||||||
|
}
|
||||||
|
resp = requests.get(
|
||||||
|
"https://api.open-meteo.com/v1/forecast",
|
||||||
|
params=params,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
cur = resp.json()["current"]
|
||||||
|
code = cur.get("weather_code", 0)
|
||||||
|
return {
|
||||||
|
"city": city["name"],
|
||||||
|
"country": city["country"],
|
||||||
|
"temperature": round(cur["temperature_2m"], 1),
|
||||||
|
"feels_like": round(cur["apparent_temperature"], 1),
|
||||||
|
"humidity": int(cur["relative_humidity_2m"]),
|
||||||
|
"pressure": int(cur["surface_pressure"]),
|
||||||
|
"wind_speed": round(cur["wind_speed_10m"], 1),
|
||||||
|
"description": WMO_DESCRIPTIONS.get(code, f"wmo:{code}"),
|
||||||
|
"owm_timestamp": int(time.time()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main(event, context):
|
||||||
|
sqs_endpoint = os.environ.get(
|
||||||
|
"SQS_ENDPOINT", "http://shared-sqs.shared-sqs.svc.cluster.local:4100"
|
||||||
|
)
|
||||||
|
access_key = os.environ.get("SQS_ACCESS_KEY", "SSAK-a9964f2723bc6d347f48d153")
|
||||||
|
secret_key = os.environ.get(
|
||||||
|
"SQS_SECRET_KEY",
|
||||||
|
"2069e1ce05aaf94efe07aee18697352879e7626df239a9c71af0e9650b43bdd6",
|
||||||
|
)
|
||||||
|
queue_name = os.environ.get("SQS_QUEUE_NAME", "weather-data")
|
||||||
|
|
||||||
|
cfg = Config(signature_version="s3v4", s3={"addressing_style": "path"})
|
||||||
|
sqs = boto3.client(
|
||||||
|
"sqs",
|
||||||
|
endpoint_url=sqs_endpoint,
|
||||||
|
aws_access_key_id=access_key,
|
||||||
|
aws_secret_access_key=secret_key,
|
||||||
|
region_name="us-east-1",
|
||||||
|
config=cfg,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
queue_url = sqs.get_queue_url(QueueName=queue_name)["QueueUrl"]
|
||||||
|
except Exception:
|
||||||
|
queue_url = sqs.create_queue(QueueName=queue_name)["QueueUrl"]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for city in CITIES:
|
||||||
|
try:
|
||||||
|
msg = fetch_city(city)
|
||||||
|
sqs.send_message(QueueUrl=queue_url, MessageBody=json.dumps(msg))
|
||||||
|
results.append({"city": msg["city"], "temp": msg["temperature"]})
|
||||||
|
except Exception as e:
|
||||||
|
results.append({"city": city["name"], "error": str(e)})
|
||||||
|
|
||||||
|
return {"status": "ok", "sent": len(results), "results": results}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
requests==2.31.0
|
||||||
|
boto3==1.34.0
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Weather Metrics",
|
||||||
|
"uid": "weather-metrics",
|
||||||
|
"timezone": "browser",
|
||||||
|
"refresh": "1m",
|
||||||
|
"time": { "from": "now-6h", "to": "now" },
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Temperature by City (°C)",
|
||||||
|
"gridPos": { "x": 0, "y": 0, "w": 24, "h": 10 },
|
||||||
|
"datasource": { "type": "grafana-postgresql-datasource", "uid": "fission-pg" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "celsius",
|
||||||
|
"custom": { "lineWidth": 2, "drawStyle": "line", "showPoints": "never", "spanNulls": true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": { "legend": { "displayMode": "table", "placement": "right" } },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"rawSql": "SELECT\n recorded_at AS time,\n temperature,\n city\nFROM weather_metrics\nWHERE $__timeFilter(recorded_at)\nORDER BY recorded_at",
|
||||||
|
"format": "time_series"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"type": "table",
|
||||||
|
"title": "Current Conditions",
|
||||||
|
"gridPos": { "x": 0, "y": 10, "w": 24, "h": 8 },
|
||||||
|
"datasource": { "type": "grafana-postgresql-datasource", "uid": "fission-pg" },
|
||||||
|
"options": { "sortBy": [{ "displayName": "City" }] },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"rawSql": "SELECT DISTINCT ON (city)\n city,\n country,\n temperature,\n feels_like,\n humidity,\n wind_speed,\n description,\n recorded_at\nFROM weather_metrics\nORDER BY city, recorded_at DESC",
|
||||||
|
"format": "table"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Humidity by City (%)",
|
||||||
|
"gridPos": { "x": 0, "y": 18, "w": 12, "h": 8 },
|
||||||
|
"datasource": { "type": "grafana-postgresql-datasource", "uid": "fission-pg" },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "percent", "min": 0, "max": 100, "custom": { "lineWidth": 2, "drawStyle": "line", "showPoints": "never", "spanNulls": true } } },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"rawSql": "SELECT\n recorded_at AS time,\n humidity,\n city\nFROM weather_metrics\nWHERE $__timeFilter(recorded_at)\nORDER BY recorded_at",
|
||||||
|
"format": "time_series"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Wind Speed by City (m/s)",
|
||||||
|
"gridPos": { "x": 12, "y": 18, "w": 12, "h": 8 },
|
||||||
|
"datasource": { "type": "grafana-postgresql-datasource", "uid": "fission-pg" },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "velocityms", "custom": { "lineWidth": 2, "drawStyle": "line", "showPoints": "never", "spanNulls": true } } },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"rawSql": "SELECT\n recorded_at AS time,\n wind_speed,\n city\nFROM weather_metrics\nWHERE $__timeFilter(recorded_at)\nORDER BY recorded_at",
|
||||||
|
"format": "time_series"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"schemaVersion": 38
|
||||||
|
},
|
||||||
|
"overwrite": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
terraform {
|
||||||
|
required_providers {
|
||||||
|
fission = {
|
||||||
|
source = "nail/fission"
|
||||||
|
version = "~> 0.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
provider "fission" {
|
||||||
|
kubeconfig_path = var.kubeconfig_path
|
||||||
|
namespace = var.namespace
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Переменные ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
variable "kubeconfig_path" {
|
||||||
|
default = "/home/naeel/.kube/config"
|
||||||
|
description = "Путь к kubeconfig."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "namespace" {
|
||||||
|
default = "fission-weather"
|
||||||
|
description = "Namespace для функций и триггеров."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "sqs_access_key" {
|
||||||
|
sensitive = true
|
||||||
|
description = "SQS Access Key."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "sqs_secret_key" {
|
||||||
|
sensitive = true
|
||||||
|
description = "SQS Secret Key."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "pg_dsn" {
|
||||||
|
sensitive = true
|
||||||
|
description = "PostgreSQL DSN для записи метрик. Пример: postgresql://user:pass@host:5432/db?sslmode=disable"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── IoT-устройство — виртуальная метеостанция ────────────────────────
|
||||||
|
|
||||||
|
resource "fission_iot_device" "weather_station" {
|
||||||
|
name = "weather-station"
|
||||||
|
device_id = "weather-station-01"
|
||||||
|
namespace = "sless"
|
||||||
|
metadata = {
|
||||||
|
type = "weather-station"
|
||||||
|
location = "multi-city"
|
||||||
|
cities = "Moscow,London,Paphos,Ulyanovsk,Santiago"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Python environment ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
resource "fission_environment" "python" {
|
||||||
|
name = "weather-python"
|
||||||
|
image = "naeel/fission-python-env:v1.1"
|
||||||
|
version = 2
|
||||||
|
namespace = var.namespace
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Пакет: fetcher ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
resource "fission_package" "fetcher" {
|
||||||
|
name = "weather-fetcher-pkg"
|
||||||
|
environment = fission_environment.python.name
|
||||||
|
namespace = var.namespace
|
||||||
|
source_dir = "${path.module}/fetcher"
|
||||||
|
deploy_type = "literal"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Пакет: consumer ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
resource "fission_package" "consumer" {
|
||||||
|
name = "weather-consumer-pkg"
|
||||||
|
environment = fission_environment.python.name
|
||||||
|
namespace = var.namespace
|
||||||
|
source_dir = "${path.module}/consumer"
|
||||||
|
deploy_type = "literal"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Функция: fetcher (читает Open-Meteo, пишет в SQS) ───────────────
|
||||||
|
# Open-Meteo: бесплатный API без ключа. https://open-meteo.com
|
||||||
|
resource "fission_function" "fetcher" {
|
||||||
|
name = "weather-fetcher"
|
||||||
|
environment = fission_environment.python.name
|
||||||
|
namespace = var.namespace
|
||||||
|
package_name = fission_package.fetcher.name
|
||||||
|
entrypoint = "main"
|
||||||
|
# Env vars задаются через K8s Secret weather-fetcher-env (namespace: var.namespace)
|
||||||
|
# Ключи: SQS_ACCESS_KEY, SQS_SECRET_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── CRON trigger: каждые 10 минут ────────────────────────────────────
|
||||||
|
|
||||||
|
resource "fission_cron_trigger" "fetcher" {
|
||||||
|
name = "weather-cron"
|
||||||
|
function = fission_function.fetcher.name
|
||||||
|
namespace = var.namespace
|
||||||
|
cron = "*/10 * * * *"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Функция: consumer (читает из SQS, пишет в PG) ────────────────────
|
||||||
|
|
||||||
|
resource "fission_function" "consumer" {
|
||||||
|
name = "weather-consumer"
|
||||||
|
environment = fission_environment.python.name
|
||||||
|
namespace = var.namespace
|
||||||
|
package_name = fission_package.consumer.name
|
||||||
|
entrypoint = "main"
|
||||||
|
# Env vars: PG_DSN задаётся через K8s Secret weather-consumer-env
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── MQ trigger: SQS queue → consumer function ────────────────────────
|
||||||
|
|
||||||
|
resource "fission_mq_trigger" "weather" {
|
||||||
|
name = "weather-mq"
|
||||||
|
function = fission_function.consumer.name
|
||||||
|
namespace = var.namespace
|
||||||
|
queue = "weather-data"
|
||||||
|
access_key = var.sqs_access_key
|
||||||
|
secret_key = var.sqs_secret_key
|
||||||
|
sqs_endpoint = "http://shared-sqs.shared-sqs.svc.cluster.local:4100"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Outputs ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
output "iot_device_phase" {
|
||||||
|
value = fission_iot_device.weather_station.phase
|
||||||
|
description = "Статус IoT-устройства weather-station."
|
||||||
|
}
|
||||||
|
|
||||||
|
output "iot_mqtt_username" {
|
||||||
|
value = fission_iot_device.weather_station.mqtt_username
|
||||||
|
description = "MQTT username для weather-station."
|
||||||
|
}
|
||||||
|
|
||||||
|
output "iot_topic_prefix" {
|
||||||
|
value = fission_iot_device.weather_station.topic_prefix
|
||||||
|
description = "MQTT topic prefix для weather-station."
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Миграция: таблица метрик погоды для weather-demo
|
||||||
|
-- Применять: psql $PG_DSN -f migration.sql
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS weather_metrics (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
city TEXT NOT NULL,
|
||||||
|
country TEXT NOT NULL,
|
||||||
|
temperature NUMERIC(5,2),
|
||||||
|
feels_like NUMERIC(5,2),
|
||||||
|
humidity INTEGER,
|
||||||
|
pressure INTEGER,
|
||||||
|
wind_speed NUMERIC(6,2),
|
||||||
|
description TEXT,
|
||||||
|
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_weather_city_time
|
||||||
|
ON weather_metrics (city, recorded_at DESC);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Скопируй в terraform.tfvars и заполни значения.
|
||||||
|
# НЕ коммитить файл с реальными секретами!
|
||||||
|
|
||||||
|
kubeconfig_path = "/home/naeel/.kube/config"
|
||||||
|
namespace = "fission-weather"
|
||||||
|
|
||||||
|
owm_api_key = "YOUR_OPENWEATHERMAP_API_KEY"
|
||||||
|
sqs_access_key = "SSAK-a9964f2723bc6d347f48d153"
|
||||||
|
sqs_secret_key = "YOUR_SQS_SECRET_KEY"
|
||||||
|
pg_dsn = "postgresql://super:PASSWORD@postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local:5432/sqsdb?sslmode=disable"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Fission helm values — PV (vcd-disk-ext4) backend
|
||||||
|
# Применять: helm upgrade fission fission-charts/fission-all --version 1.22.1 \
|
||||||
|
# -n fission -f helm/fission-values-pv.yaml
|
||||||
|
#
|
||||||
|
# Секретов нет — local storage не требует credentials.
|
||||||
|
# ПЕРЕД upgrade: kubectl delete deploy executor router -n fission
|
||||||
|
|
||||||
|
authentication:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
defaultNamespace: fission-functions
|
||||||
|
|
||||||
|
image: naeel/fission-bundle
|
||||||
|
imageTag: v1.23.0
|
||||||
|
fetcher:
|
||||||
|
image: naeel/fetcher
|
||||||
|
imageTag: v1.23.0
|
||||||
|
|
||||||
|
persistence:
|
||||||
|
enabled: true
|
||||||
|
storageType: local
|
||||||
|
storageClass: vcd-disk-ext4
|
||||||
|
size: 2Gi
|
||||||
|
accessMode: ReadWriteOnce
|
||||||
|
|
||||||
|
repository: ""
|
||||||
|
routerServiceType: ClusterIP
|
||||||
|
serviceType: ClusterIP
|
||||||
|
|
||||||
|
analytics: false
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Fission helm values — S3 backend (ngcloud msk-1)
|
||||||
|
# Применять: helm upgrade fission fission-charts/fission-all --version 1.22.1 \
|
||||||
|
# -n fission -f helm/fission-values.yaml -f helm/fission-s3-secret.yaml
|
||||||
|
#
|
||||||
|
# Секреты — в helm/fission-s3-secret.yaml (в .gitignore, не коммитить!)
|
||||||
|
# Вернуться на PV: -f helm/fission-values-pv.yaml (без s3-secret)
|
||||||
|
# ПЕРЕД upgrade: kubectl delete deploy executor router -n fission
|
||||||
|
|
||||||
|
authentication:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
defaultNamespace: fission-functions
|
||||||
|
|
||||||
|
image: naeel/fission-bundle
|
||||||
|
imageTag: v1.23.0
|
||||||
|
fetcher:
|
||||||
|
image: naeel/fetcher
|
||||||
|
imageTag: v1.23.0
|
||||||
|
|
||||||
|
persistence:
|
||||||
|
enabled: true
|
||||||
|
storageType: s3
|
||||||
|
s3:
|
||||||
|
bucketName: sless-functions
|
||||||
|
subDir: fission
|
||||||
|
region: msk-1
|
||||||
|
endPoint: https://s3.msk-1.ngcloud.ru
|
||||||
|
# accessKeyId и secretAccessKey — в fission-s3-secret.yaml
|
||||||
|
|
||||||
|
repository: ""
|
||||||
|
routerServiceType: ClusterIP
|
||||||
|
serviceType: ClusterIP
|
||||||
|
|
||||||
|
analytics: false
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Демо-пайплайн: IoT → SQS → Fission
|
||||||
|
|
||||||
|
> ⚠️ LEGACY — написано до реализации MQ-триггера.
|
||||||
|
> Сейчас MQ-триггер реализован (см. TODO.txt).
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
1. **Крон-функция (Fission)**
|
||||||
|
- Парсит данные о погоде с сайта для нескольких городов
|
||||||
|
- Эмулирует IoT-датчики: отправляет данные в IoT-сервис (MQTT/HTTP)
|
||||||
|
|
||||||
|
2. **IoT-сервис**
|
||||||
|
- Принимает данные от "датчиков"
|
||||||
|
- Публикует сообщения в очередь (SQS или аналог)
|
||||||
|
|
||||||
|
3. **Очередь (SQS)**
|
||||||
|
- Хранит сообщения от IoT
|
||||||
|
- Триггерит Fission-функцию при появлении новых данных (MessageQueue Trigger)
|
||||||
|
|
||||||
|
4. **Fission-функция**
|
||||||
|
- Получает данные из очереди
|
||||||
|
- Записывает их в таблицу (PostgreSQL, ClickHouse и т.д.)
|
||||||
|
|
||||||
|
## Требования к Fission
|
||||||
|
|
||||||
|
> ⚠️ LEGACY: MQ-триггер реализован через sqs-consumer (май 2026).
|
||||||
|
> Этот раздел оставлен для исторического контекста.
|
||||||
|
|
||||||
|
- Необходим MQ-триггер (MessageQueue Trigger) для автоматического запуска функции по сообщениям из очереди.
|
||||||
|
- Сейчас в Fission есть HTTP, Cron, Event, но нет универсального MQ-триггера.
|
||||||
|
- Возможные варианты:
|
||||||
|
- Реализовать внешний watcher (SQS/Kafka/RabbitMQ → invoke HTTP endpoint Fission)
|
||||||
|
- Добавить поддержку MQ-триггера в сам Fission (новый CRD + контроллер)
|
||||||
|
|
||||||
|
## Примечания
|
||||||
|
|
||||||
|
- Все компоненты связаны через API/очереди, каждый слой изолирован.
|
||||||
|
- Такой пайплайн типовой для облачных платформ и легко масштабируется.
|
||||||
|
- Для MVP достаточно watcher-а очереди, который вызывает функцию через HTTP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Если потребуется — расписать детальный план интеграции или примеры кода для каждого этапа.
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
FROM ghcr.io/fission/python-env:latest
|
FROM ghcr.io/fission/python-env:latest
|
||||||
|
RUN pip install --no-cache-dir boto3==1.34.0 requests==2.31.0 psycopg2-binary==2.9.9
|
||||||
COPY server.py /app/server.py
|
COPY server.py /app/server.py
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
migrate_s3_to_pv.py — перелив архивов функций из S3 → storagesvc (PV-backend)
|
||||||
|
|
||||||
|
Запуск: kubectl exec -n fission deploy/storagesvc -- python3 /tmp/migrate.py
|
||||||
|
ИЛИ: запустить как Job в кластере
|
||||||
|
|
||||||
|
Что делает:
|
||||||
|
1. Читает все Package CRD во всех namespace
|
||||||
|
2. Для каждого пакета с URL s3://... скачивает архив из S3
|
||||||
|
3. Загружает в storagesvc HTTP API → получает новый URL
|
||||||
|
4. Обновляет Package CRD: .spec.deployment.url = новый URL
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
# S3 credentials (из env или хардкод для ручного запуска)
|
||||||
|
S3_ENDPOINT = os.environ.get("S3_ENDPOINT", "https://s3.msk-1.ngcloud.ru")
|
||||||
|
S3_BUCKET = os.environ.get("S3_BUCKET", "sless-functions")
|
||||||
|
S3_ACCESS_KEY = os.environ.get("S3_ACCESS_KEY", "0GLQRD38H4I6RBDB0EWJ")
|
||||||
|
S3_SECRET_KEY = os.environ.get("S3_SECRET_KEY", "eTFibiHmBd96IApj9PYsboTR6OBoD7osxoarHykw")
|
||||||
|
S3_REGION = os.environ.get("S3_REGION", "msk-1")
|
||||||
|
|
||||||
|
STORAGESVC_URL = os.environ.get("STORAGESVC_URL", "http://storagesvc.fission.svc.cluster.local")
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
def kubectl(args, input_data=None):
|
||||||
|
cmd = ["kubectl"] + args
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True, input=input_data)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError(f"kubectl {' '.join(args)} failed: {r.stderr}")
|
||||||
|
return r.stdout
|
||||||
|
|
||||||
|
def get_all_packages():
|
||||||
|
out = kubectl(["get", "packages", "--all-namespaces", "-o", "json"])
|
||||||
|
return json.loads(out)["items"]
|
||||||
|
|
||||||
|
def is_s3_url(url):
|
||||||
|
return url and url.startswith("s3://")
|
||||||
|
|
||||||
|
def s3_key_from_url(url):
|
||||||
|
# s3://sless-functions/fission/abc123 → fission/abc123
|
||||||
|
path = url[len(f"s3://{S3_BUCKET}/"):]
|
||||||
|
return path
|
||||||
|
|
||||||
|
def download_from_s3(s3_key):
|
||||||
|
import boto3
|
||||||
|
s3 = boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=S3_ENDPOINT,
|
||||||
|
aws_access_key_id=S3_ACCESS_KEY,
|
||||||
|
aws_secret_access_key=S3_SECRET_KEY,
|
||||||
|
region_name=S3_REGION,
|
||||||
|
)
|
||||||
|
obj = s3.get_object(Bucket=S3_BUCKET, Key=s3_key)
|
||||||
|
return obj["Body"].read()
|
||||||
|
|
||||||
|
def upload_to_storagesvc(data):
|
||||||
|
import urllib.request, uuid
|
||||||
|
boundary = uuid.uuid4().hex
|
||||||
|
body = (
|
||||||
|
f"--{boundary}\r\n"
|
||||||
|
f'Content-Disposition: form-data; name="uploadfile"; filename="archive.zip"\r\n'
|
||||||
|
f"Content-Type: application/octet-stream\r\n\r\n"
|
||||||
|
).encode() + data + f"\r\n--{boundary}--\r\n".encode()
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{STORAGESVC_URL}/v1/archive",
|
||||||
|
data=body,
|
||||||
|
method="POST",
|
||||||
|
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
result = json.loads(resp.read())
|
||||||
|
return result["id"] # storagesvc возвращает {"id": "http://storagesvc.../v1/archive?id=..."}
|
||||||
|
|
||||||
|
def update_package_url(ns, name, new_url):
|
||||||
|
patch = json.dumps({"spec": {"deployment": {"url": new_url, "type": "url"}}})
|
||||||
|
kubectl(["patch", "package", name, "-n", ns, "--type=merge", f"--patch={patch}"])
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
import boto3
|
||||||
|
except ImportError:
|
||||||
|
print("ERROR: boto3 не установлен. Запусти: pip install boto3")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
packages = get_all_packages()
|
||||||
|
total = len(packages)
|
||||||
|
migrated = 0
|
||||||
|
skipped = 0
|
||||||
|
errors = 0
|
||||||
|
|
||||||
|
print(f"Всего пакетов: {total}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
for pkg in packages:
|
||||||
|
ns = pkg["metadata"]["namespace"]
|
||||||
|
name = pkg["metadata"]["name"]
|
||||||
|
url = pkg.get("spec", {}).get("deployment", {}).get("url", "")
|
||||||
|
|
||||||
|
if not is_s3_url(url):
|
||||||
|
print(f" SKIP {ns}/{name} — url: {url or '(empty)'}")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
s3_key = s3_key_from_url(url)
|
||||||
|
print(f" MIG {ns}/{name} s3://{S3_BUCKET}/{s3_key}", end=" ", flush=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = download_from_s3(s3_key)
|
||||||
|
new_id = upload_to_storagesvc(data)
|
||||||
|
new_url = f"{STORAGESVC_URL}/v1/archive?id={new_id}" if not new_id.startswith("http") else new_id
|
||||||
|
update_package_url(ns, name, new_url)
|
||||||
|
print(f"→ {new_url}")
|
||||||
|
migrated += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ERROR: {e}")
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f"Готово: перенесено={migrated} пропущено={skipped} ошибок={errors}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+994
@@ -0,0 +1,994 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# test_heavy.sh — поиск багов: большой код, CPU-нагрузка, большой ответ,
|
||||||
|
# архивы из ZIP, быстрые серии invoke, параллельные вызовы,
|
||||||
|
# медленные функции, давление по памяти.
|
||||||
|
# Все 4 языка: Python, Node.js, Ruby, PHP.
|
||||||
|
#
|
||||||
|
# ВАЖНО: Ruby и PHP invoke ранее не тестировались вообще!
|
||||||
|
# Умеренная нагрузка (кластер небольшой, но нагрузка реальная).
|
||||||
|
#
|
||||||
|
# Запуск: bash scripts/test_heavy.sh
|
||||||
|
# На ВМ: bash ~/terra/fission/scripts/test_heavy.sh
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
source "${ROOT_DIR}/lib.sh"
|
||||||
|
|
||||||
|
RUN_ID=$(mk_run_id)
|
||||||
|
SUB="livetest@test.local"
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "════════════════════════════════════════════════════════"
|
||||||
|
echo " test_heavy.sh run=${RUN_ID}"
|
||||||
|
echo "════════════════════════════════════════════════════════"
|
||||||
|
|
||||||
|
declare -a ALL_FNS=()
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$TMP" 2>/dev/null || true
|
||||||
|
for fn in "${ALL_FNS[@]:-}"; do
|
||||||
|
curl -s -X DELETE "${BASE}/functions/${fn}" \
|
||||||
|
-H "X-Auth-Token: ${SUB}" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# ─── helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# reg FN — регистрирует имя функции для cleanup
|
||||||
|
reg() { ALL_FNS+=("$1"); }
|
||||||
|
|
||||||
|
# create_json LANG FN CODE_FILE — создаёт функцию из файла с кодом
|
||||||
|
create_json() {
|
||||||
|
local lang="$1" fn="$2" codefile="$3"
|
||||||
|
local code_escaped
|
||||||
|
code_escaped=$(cat "$codefile" | json_escape)
|
||||||
|
reg "$fn"
|
||||||
|
local R
|
||||||
|
R=$(curl -s -w "\n%{http_code}" -X POST "${BASE}/functions" \
|
||||||
|
-H "X-Auth-Token: ${SUB}" -H "Content-Type: application/json" \
|
||||||
|
-d "{\"name\":\"${fn}\",\"language\":\"${lang}\",\"code\":${code_escaped}}" 2>/dev/null)
|
||||||
|
check_http "$(echo "$R" | tail -1)" "201" "${lang^^} ${fn} CREATE → 201"
|
||||||
|
}
|
||||||
|
|
||||||
|
# invoke_once FN EXPECTED LABEL — один invoke, без retry
|
||||||
|
invoke_once() {
|
||||||
|
local fn="$1" expected="$2" label="$3"
|
||||||
|
local resp status raw err
|
||||||
|
resp=$(curl -s --max-time 60 -X POST "${BASE}/functions/${fn}/invoke" \
|
||||||
|
-H "X-Auth-Token: ${SUB}" -H "Content-Type: application/json" -d '{}')
|
||||||
|
status=$(json_field "$resp" status)
|
||||||
|
raw=$(json_field "$resp" response_raw)
|
||||||
|
err=$(json_field "$resp" error)
|
||||||
|
echo " fn=$fn status=$status raw=$(printf '%s' "$raw" | head -c 100) err=$(printf '%s' "$err" | head -c 80)"
|
||||||
|
if [ "$status" = "200" ] && printf '%s' "$raw" | grep -qF "$expected"; then
|
||||||
|
pass "$label → 200 + '${expected}'"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
fail "$label → status=${status:-?} (ожидали 200 + '${expected}')"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# SECTION 1: БОЛЬШОЙ КОД — ~100 строк на язык, из inline-кода
|
||||||
|
# Ищем: лимиты на размер кода, проблемы загрузки, синтаксические баги
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
echo ""
|
||||||
|
echo "────────────────────────────────────────────────────────"
|
||||||
|
echo " S1: БОЛЬШОЙ КОД (Python / Node.js / Ruby / PHP)"
|
||||||
|
echo "────────────────────────────────────────────────────────"
|
||||||
|
|
||||||
|
FN_BIG_PY="t-big-py-${RUN_ID}"
|
||||||
|
cat > "${TMP}/big_py.py" << 'PYEOF'
|
||||||
|
import math, hashlib, json
|
||||||
|
|
||||||
|
CONSTS = {f"k{i}": i * 3.14159265 for i in range(20)}
|
||||||
|
|
||||||
|
def fib(n):
|
||||||
|
a, b = 0, 1
|
||||||
|
for _ in range(n - 1): a, b = b, a + b
|
||||||
|
return b
|
||||||
|
|
||||||
|
def sieve(limit):
|
||||||
|
is_p = bytearray([1]) * (limit + 1)
|
||||||
|
is_p[0] = is_p[1] = 0
|
||||||
|
for i in range(2, int(limit**0.5) + 1):
|
||||||
|
if is_p[i]:
|
||||||
|
for j in range(i*i, limit+1, i): is_p[j] = 0
|
||||||
|
return sum(is_p)
|
||||||
|
|
||||||
|
def rot13(s):
|
||||||
|
return s.translate(str.maketrans(
|
||||||
|
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
||||||
|
'nopqrstuvwxyzabcdefghijklmNOPQRSTUVWXYZABCDEFGHIJKLM'))
|
||||||
|
|
||||||
|
def caesar(s, shift=7):
|
||||||
|
res = []
|
||||||
|
for c in s:
|
||||||
|
if 'a' <= c <= 'z': res.append(chr((ord(c) - 97 + shift) % 26 + 97))
|
||||||
|
elif 'A' <= c <= 'Z': res.append(chr((ord(c) - 65 + shift) % 26 + 65))
|
||||||
|
else: res.append(c)
|
||||||
|
return ''.join(res)
|
||||||
|
|
||||||
|
def mat_trace(m):
|
||||||
|
return sum(m[i][i] for i in range(len(m)))
|
||||||
|
|
||||||
|
def palindrome(s):
|
||||||
|
t = s.lower().replace(' ', '')
|
||||||
|
return t == t[::-1]
|
||||||
|
|
||||||
|
def flatten(lst):
|
||||||
|
out = []
|
||||||
|
for item in lst:
|
||||||
|
if isinstance(item, list): out.extend(flatten(item))
|
||||||
|
else: out.append(item)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def checksum(data):
|
||||||
|
return hashlib.sha256(data.encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
def running_sum(lst):
|
||||||
|
total = 0; out = []
|
||||||
|
for v in lst: total += v; out.append(total)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def count_distinct(lst):
|
||||||
|
return len(set(lst))
|
||||||
|
|
||||||
|
def group_by_mod(lst, n):
|
||||||
|
g = {i: [] for i in range(n)}
|
||||||
|
for v in lst: g[v % n].append(v)
|
||||||
|
return {str(k): len(v) for k, v in g.items()}
|
||||||
|
|
||||||
|
def pascal_row(n):
|
||||||
|
row = [1]
|
||||||
|
for i in range(1, n + 1): row.append(row[-1] * (n - i + 1) // i)
|
||||||
|
return row
|
||||||
|
|
||||||
|
def deduplicate(lst):
|
||||||
|
seen = set(); out = []
|
||||||
|
for v in lst:
|
||||||
|
if v not in seen: seen.add(v); out.append(v)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def reverse_words(s):
|
||||||
|
return ' '.join(s.split()[::-1])
|
||||||
|
|
||||||
|
def count_vowels(s):
|
||||||
|
return sum(1 for c in s.lower() if c in 'aeiou')
|
||||||
|
|
||||||
|
def is_prime(n):
|
||||||
|
if n < 2: return False
|
||||||
|
if n == 2: return True
|
||||||
|
if n % 2 == 0: return False
|
||||||
|
for i in range(3, int(math.sqrt(n)) + 1, 2):
|
||||||
|
if n % i == 0: return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def main(event, context):
|
||||||
|
mat = [[1,2,3,4],[5,6,7,8],[9,10,11,12],[13,14,15,16]]
|
||||||
|
data = list(range(1, 51))
|
||||||
|
nested = [[1,[2,3]],[4,[5,[6,7]]],8]
|
||||||
|
result = {
|
||||||
|
"tag": "big-code-py-ok",
|
||||||
|
"fib_25": fib(25),
|
||||||
|
"primes_50": sieve(50),
|
||||||
|
"rot13": rot13("Hello Fission"),
|
||||||
|
"caesar": caesar("heavy"),
|
||||||
|
"const_sum": round(sum(CONSTS.values()), 2),
|
||||||
|
"mat_trace": mat_trace(mat),
|
||||||
|
"palindrome": palindrome("racecar"),
|
||||||
|
"distinct": count_distinct([1,2,2,3,3,3,4]),
|
||||||
|
"flat_len": len(flatten(nested)),
|
||||||
|
"checksum": checksum("fission-heavy-test"),
|
||||||
|
"rsum_last": running_sum(data)[-1],
|
||||||
|
"pascal_4": pascal_row(4),
|
||||||
|
"dedup_len": len(deduplicate([1,2,2,3,3,3,4,5,5])),
|
||||||
|
"rev_words": reverse_words("big code python test"),
|
||||||
|
"vowels": count_vowels("fission heavy test"),
|
||||||
|
"is_prime_97": is_prime(97),
|
||||||
|
"groups_3": group_by_mod(data[:30], 3),
|
||||||
|
}
|
||||||
|
return json.dumps(result), 200
|
||||||
|
PYEOF
|
||||||
|
create_json "python" "$FN_BIG_PY" "${TMP}/big_py.py"
|
||||||
|
|
||||||
|
FN_BIG_NODE="t-big-node-${RUN_ID}"
|
||||||
|
cat > "${TMP}/big_node.js" << 'JSEOF'
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const CONSTS = {};
|
||||||
|
for (let i = 0; i < 20; i++) CONSTS[`k${i}`] = i * Math.PI;
|
||||||
|
|
||||||
|
function fib(n) {
|
||||||
|
let a = 0, b = 1;
|
||||||
|
for (let i = 1; i < n; i++) { let t = a + b; a = b; b = t; }
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
function sieve(limit) {
|
||||||
|
const isP = new Uint8Array(limit + 1).fill(1);
|
||||||
|
isP[0] = isP[1] = 0;
|
||||||
|
for (let i = 2; i * i <= limit; i++)
|
||||||
|
if (isP[i]) for (let j = i*i; j <= limit; j += i) isP[j] = 0;
|
||||||
|
return isP.reduce((s, v) => s + v, 0);
|
||||||
|
}
|
||||||
|
function rot13(s) {
|
||||||
|
return s.replace(/[a-zA-Z]/g, c => {
|
||||||
|
const b = c <= 'Z' ? 65 : 97;
|
||||||
|
return String.fromCharCode((c.charCodeAt(0) - b + 13) % 26 + b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function caesar(s, shift = 7) {
|
||||||
|
return s.replace(/[a-zA-Z]/g, c => {
|
||||||
|
const b = c <= 'Z' ? 65 : 97;
|
||||||
|
return String.fromCharCode((c.charCodeAt(0) - b + shift) % 26 + b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function matTrace(m) { return m.reduce((s, row, i) => s + row[i], 0); }
|
||||||
|
function palindrome(s) { const t = s.toLowerCase().replace(/ /g, ''); return t === t.split('').reverse().join(''); }
|
||||||
|
function flatten(arr) { return arr.reduce((a, v) => a.concat(Array.isArray(v) ? flatten(v) : v), []); }
|
||||||
|
function checksum(d) { return crypto.createHash('sha256').update(d).digest('hex').slice(0, 16); }
|
||||||
|
function runningSum(arr) { let t = 0; return arr.map(v => (t += v, t)); }
|
||||||
|
function countDistinct(arr) { return new Set(arr).size; }
|
||||||
|
function deduplicate(arr) { const s = new Set(); return arr.filter(v => !s.has(v) && s.add(v)); }
|
||||||
|
function groupByMod(arr, n) { const g = {}; for (let i = 0; i < n; i++) g[i] = 0; arr.forEach(v => g[v % n]++); return g; }
|
||||||
|
function pascalRow(n) { const r = [1]; for (let i = 1; i <= n; i++) r.push((r[i-1] * (n-i+1) / i) | 0); return r; }
|
||||||
|
function reverseWords(s) { return s.split(' ').reverse().join(' '); }
|
||||||
|
function countVowels(s) { return (s.toLowerCase().match(/[aeiou]/g) || []).length; }
|
||||||
|
function isPrime(n) {
|
||||||
|
if (n < 2) return false;
|
||||||
|
for (let i = 2; i * i <= n; i++) if (n % i === 0) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mat = [[1,2,3,4],[5,6,7,8],[9,10,11,12],[13,14,15,16]];
|
||||||
|
const nested = [[1,[2,3]],[4,[5,[6,7]]],8];
|
||||||
|
|
||||||
|
module.exports = async function(ctx) {
|
||||||
|
const data = Array.from({length: 50}, (_, i) => i + 1);
|
||||||
|
const result = {
|
||||||
|
tag: "big-code-node-ok",
|
||||||
|
fib_25: fib(25),
|
||||||
|
primes_50: sieve(50),
|
||||||
|
rot13: rot13("Hello Fission"),
|
||||||
|
caesar: caesar("heavy"),
|
||||||
|
const_sum: Math.round(Object.values(CONSTS).reduce((a,b) => a+b, 0) * 100) / 100,
|
||||||
|
mat_trace: matTrace(mat),
|
||||||
|
palindrome: palindrome("racecar"),
|
||||||
|
distinct: countDistinct([1,2,2,3,3,3,4]),
|
||||||
|
flat_len: flatten(nested).length,
|
||||||
|
checksum: checksum("fission-heavy-test"),
|
||||||
|
rsum_last: runningSum(data).at(-1),
|
||||||
|
pascal_4: pascalRow(4),
|
||||||
|
dedup_len: deduplicate([1,2,2,3,3,3,4,5,5]).length,
|
||||||
|
rev_words: reverseWords("big code node test"),
|
||||||
|
vowels: countVowels("fission heavy test"),
|
||||||
|
is_prime_97: isPrime(97),
|
||||||
|
groups_3: groupByMod(data.slice(0,30), 3),
|
||||||
|
};
|
||||||
|
return { status: 200, body: JSON.stringify(result) };
|
||||||
|
};
|
||||||
|
JSEOF
|
||||||
|
create_json "nodejs" "$FN_BIG_NODE" "${TMP}/big_node.js"
|
||||||
|
|
||||||
|
FN_BIG_RUBY="t-big-ruby-${RUN_ID}"
|
||||||
|
cat > "${TMP}/big_ruby.rb" << 'RBEOF'
|
||||||
|
require 'json'
|
||||||
|
require 'digest'
|
||||||
|
|
||||||
|
CONSTS = (0...20).map { |i| [:"k#{i}", i * Math::PI] }.to_h
|
||||||
|
|
||||||
|
def fib(n)
|
||||||
|
a, b = 0, 1
|
||||||
|
(n - 1).times { a, b = b, a + b }
|
||||||
|
b
|
||||||
|
end
|
||||||
|
|
||||||
|
def sieve(limit)
|
||||||
|
is_p = Array.new(limit + 1, true)
|
||||||
|
is_p[0] = is_p[1] = false
|
||||||
|
(2..Math.sqrt(limit).to_i).each do |i|
|
||||||
|
next unless is_p[i]
|
||||||
|
(i*i..limit).step(i) { |j| is_p[j] = false }
|
||||||
|
end
|
||||||
|
is_p.count(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def rot13(s)
|
||||||
|
s.tr('a-zA-Z', 'n-za-mN-ZA-M')
|
||||||
|
end
|
||||||
|
|
||||||
|
def caesar(s, shift = 7)
|
||||||
|
s.chars.map do |c|
|
||||||
|
if c =~ /[a-z]/ then ((c.ord - 97 + shift) % 26 + 97).chr
|
||||||
|
elsif c =~ /[A-Z]/ then ((c.ord - 65 + shift) % 26 + 65).chr
|
||||||
|
else c
|
||||||
|
end
|
||||||
|
end.join
|
||||||
|
end
|
||||||
|
|
||||||
|
def mat_trace(m)
|
||||||
|
m.each_with_index.sum { |row, i| row[i] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def palindrome?(s)
|
||||||
|
t = s.downcase.delete(' ')
|
||||||
|
t == t.reverse
|
||||||
|
end
|
||||||
|
|
||||||
|
def flatten_nested(lst)
|
||||||
|
lst.each_with_object([]) do |item, out|
|
||||||
|
item.is_a?(Array) ? out.concat(flatten_nested(item)) : out << item
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def checksum(data)
|
||||||
|
Digest::SHA256.hexdigest(data)[0, 16]
|
||||||
|
end
|
||||||
|
|
||||||
|
def running_sum(arr)
|
||||||
|
total = 0
|
||||||
|
arr.map { |v| total += v; total }
|
||||||
|
end
|
||||||
|
|
||||||
|
def count_distinct(arr)
|
||||||
|
arr.uniq.length
|
||||||
|
end
|
||||||
|
|
||||||
|
def group_by_mod(arr, n)
|
||||||
|
(0...n).each_with_object({}) { |i, h| h[i.to_s] = arr.count { |v| v % n == i } }
|
||||||
|
end
|
||||||
|
|
||||||
|
def pascal_row(n)
|
||||||
|
row = [1]
|
||||||
|
(1..n).each { |i| row << row[-1] * (n - i + 1) / i }
|
||||||
|
row
|
||||||
|
end
|
||||||
|
|
||||||
|
def reverse_words(s)
|
||||||
|
s.split.reverse.join(' ')
|
||||||
|
end
|
||||||
|
|
||||||
|
def count_vowels(s)
|
||||||
|
s.downcase.count('aeiou')
|
||||||
|
end
|
||||||
|
|
||||||
|
def is_prime(n)
|
||||||
|
return false if n < 2
|
||||||
|
(2..Math.sqrt(n).to_i).none? { |i| n % i == 0 }
|
||||||
|
end
|
||||||
|
|
||||||
|
def handler(ctx)
|
||||||
|
mat = [[1,2,3,4],[5,6,7,8],[9,10,11,12],[13,14,15,16]]
|
||||||
|
data = (1..50).to_a
|
||||||
|
nested = [[1,[2,3]],[4,[5,[6,7]]],8]
|
||||||
|
result = {
|
||||||
|
tag: "big-code-ruby-ok",
|
||||||
|
fib_25: fib(25),
|
||||||
|
primes_50: sieve(50),
|
||||||
|
rot13: rot13("Hello Fission"),
|
||||||
|
caesar: caesar("heavy"),
|
||||||
|
const_sum: CONSTS.values.sum.round(2),
|
||||||
|
mat_trace: mat_trace(mat),
|
||||||
|
palindrome: palindrome?("racecar"),
|
||||||
|
distinct: count_distinct([1,2,2,3,3,3,4]),
|
||||||
|
flat_len: flatten_nested(nested).length,
|
||||||
|
checksum: checksum("fission-heavy-test"),
|
||||||
|
rsum_last: running_sum(data).last,
|
||||||
|
pascal_4: pascal_row(4),
|
||||||
|
rev_words: reverse_words("big code ruby test"),
|
||||||
|
vowels: count_vowels("fission heavy test"),
|
||||||
|
is_prime_97: is_prime(97),
|
||||||
|
groups_3: group_by_mod(data.first(30), 3),
|
||||||
|
}
|
||||||
|
{ statusCode: 200, body: result.to_json }
|
||||||
|
end
|
||||||
|
RBEOF
|
||||||
|
create_json "ruby" "$FN_BIG_RUBY" "${TMP}/big_ruby.rb"
|
||||||
|
|
||||||
|
FN_BIG_PHP="t-big-php-${RUN_ID}"
|
||||||
|
cat > "${TMP}/big_php.php" << 'PHPEOF'
|
||||||
|
<?php
|
||||||
|
$CONSTS = [];
|
||||||
|
for ($i = 0; $i < 20; $i++) $CONSTS["k{$i}"] = $i * M_PI;
|
||||||
|
|
||||||
|
function fib($n) {
|
||||||
|
$a = 0; $b = 1;
|
||||||
|
for ($i = 1; $i < $n; $i++) { $t = $a + $b; $a = $b; $b = $t; }
|
||||||
|
return $b;
|
||||||
|
}
|
||||||
|
function sieve($limit) {
|
||||||
|
$isP = array_fill(0, $limit + 1, true);
|
||||||
|
$isP[0] = $isP[1] = false;
|
||||||
|
for ($i = 2; $i * $i <= $limit; $i++)
|
||||||
|
if ($isP[$i]) for ($j = $i*$i; $j <= $limit; $j += $i) $isP[$j] = false;
|
||||||
|
return array_sum(array_map('intval', $isP));
|
||||||
|
}
|
||||||
|
function rot13($s) { return str_rot13($s); }
|
||||||
|
function caesar($s, $shift = 7) {
|
||||||
|
$r = '';
|
||||||
|
for ($i = 0; $i < strlen($s); $i++) {
|
||||||
|
$c = $s[$i];
|
||||||
|
if ($c >= 'a' && $c <= 'z') $r .= chr((ord($c) - 97 + $shift) % 26 + 97);
|
||||||
|
elseif ($c >= 'A' && $c <= 'Z') $r .= chr((ord($c) - 65 + $shift) % 26 + 65);
|
||||||
|
else $r .= $c;
|
||||||
|
}
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
function matTrace($m) {
|
||||||
|
$s = 0;
|
||||||
|
foreach ($m as $i => $row) $s += $row[$i];
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
function palindrome($s) {
|
||||||
|
$t = str_replace(' ', '', strtolower($s));
|
||||||
|
return $t === strrev($t);
|
||||||
|
}
|
||||||
|
function checksumStr($data) { return substr(hash('sha256', $data), 0, 16); }
|
||||||
|
function runningSum($arr) {
|
||||||
|
$t = 0; $out = [];
|
||||||
|
foreach ($arr as $v) { $t += $v; $out[] = $t; }
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
function countDistinct($arr) { return count(array_unique($arr)); }
|
||||||
|
function groupByMod($arr, $n) {
|
||||||
|
$g = array_fill(0, $n, 0);
|
||||||
|
foreach ($arr as $v) $g[$v % $n]++;
|
||||||
|
return $g;
|
||||||
|
}
|
||||||
|
function pascalRow($n) {
|
||||||
|
$row = [1];
|
||||||
|
for ($i = 1; $i <= $n; $i++) $row[] = intdiv($row[$i-1] * ($n-$i+1), $i);
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
function reverseWords($s) { return implode(' ', array_reverse(explode(' ', $s))); }
|
||||||
|
function countVowels($s) { return preg_match_all('/[aeiou]/i', $s); }
|
||||||
|
function isPrime($n) {
|
||||||
|
if ($n < 2) return false;
|
||||||
|
for ($i = 2; $i * $i <= $n; $i++) if ($n % $i === 0) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handler($ctx) {
|
||||||
|
global $CONSTS;
|
||||||
|
$mat = [[1,2,3,4],[5,6,7,8],[9,10,11,12],[13,14,15,16]];
|
||||||
|
$data = range(1, 50);
|
||||||
|
$constSum = 0; foreach ($CONSTS as $v) $constSum += $v;
|
||||||
|
$rsum = runningSum($data);
|
||||||
|
$result = [
|
||||||
|
'tag' => 'big-code-php-ok',
|
||||||
|
'fib_25' => fib(25),
|
||||||
|
'primes_50' => sieve(50),
|
||||||
|
'rot13' => rot13('Hello Fission'),
|
||||||
|
'caesar' => caesar('heavy'),
|
||||||
|
'const_sum' => round($constSum, 2),
|
||||||
|
'mat_trace' => matTrace($mat),
|
||||||
|
'palindrome' => palindrome('racecar'),
|
||||||
|
'distinct' => countDistinct([1,2,2,3,3,3,4]),
|
||||||
|
'checksum' => checksumStr('fission-heavy-test'),
|
||||||
|
'rsum_last' => end($rsum),
|
||||||
|
'pascal_4' => pascalRow(4),
|
||||||
|
'rev_words' => reverseWords('big code php test'),
|
||||||
|
'vowels' => countVowels('fission heavy test'),
|
||||||
|
'is_prime_97'=> isPrime(97),
|
||||||
|
'groups_3' => groupByMod(array_slice($data, 0, 30), 3),
|
||||||
|
];
|
||||||
|
$ctx["response"]->getBody()->write(json_encode($result));
|
||||||
|
}
|
||||||
|
PHPEOF
|
||||||
|
create_json "php" "$FN_BIG_PHP" "${TMP}/big_php.php"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ">>> S1: invoke больших функций — ждём cold start всех 4 языков..."
|
||||||
|
for fn_tag in \
|
||||||
|
"${FN_BIG_PY}:big-code-py-ok:Python" \
|
||||||
|
"${FN_BIG_NODE}:big-code-node-ok:Node.js" \
|
||||||
|
"${FN_BIG_RUBY}:big-code-ruby-ok:Ruby" \
|
||||||
|
"${FN_BIG_PHP}:big-code-php-ok:PHP"; do
|
||||||
|
fn="${fn_tag%%:*}"; rest="${fn_tag#*:}"; tag="${rest%%:*}"; label="${rest#*:}"
|
||||||
|
(
|
||||||
|
if wait_invoke "$SUB" "$fn" "$tag" 20 6; then
|
||||||
|
echo "OK"
|
||||||
|
else
|
||||||
|
echo "FAIL"
|
||||||
|
fi
|
||||||
|
) > "${TMP}/s1-${fn}.out" &
|
||||||
|
done
|
||||||
|
wait
|
||||||
|
|
||||||
|
for fn_tag in \
|
||||||
|
"${FN_BIG_PY}:big-code-py-ok:Python" \
|
||||||
|
"${FN_BIG_NODE}:big-code-node-ok:Node.js" \
|
||||||
|
"${FN_BIG_RUBY}:big-code-ruby-ok:Ruby" \
|
||||||
|
"${FN_BIG_PHP}:big-code-php-ok:PHP"; do
|
||||||
|
fn="${fn_tag%%:*}"; rest="${fn_tag#*:}"; tag="${rest%%:*}"; label="${rest#*:}"
|
||||||
|
result=$(cat "${TMP}/s1-${fn}.out" 2>/dev/null || echo "FAIL")
|
||||||
|
if [ "$result" = "OK" ]; then
|
||||||
|
pass "S1 $label BIG CODE invoke → 200"
|
||||||
|
else
|
||||||
|
fail "S1 $label BIG CODE invoke — не стабилизировался"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# SECTION 2: CPU BURN — рекурсивный fib, нагрузка на CPU пода
|
||||||
|
# Ищем: OOM kill, timeout, зависание пула, медленный poolmgr
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
echo ""
|
||||||
|
echo "────────────────────────────────────────────────────────"
|
||||||
|
echo " S2: CPU BURN — рекурсивный fib (Python/Node.js/Ruby/PHP)"
|
||||||
|
echo "────────────────────────────────────────────────────────"
|
||||||
|
|
||||||
|
FN_CPU_PY="t-cpu-py-${RUN_ID}"
|
||||||
|
cat > "${TMP}/cpu_py.py" << 'PYEOF'
|
||||||
|
def fib(n):
|
||||||
|
if n < 2: return n
|
||||||
|
return fib(n-1) + fib(n-2)
|
||||||
|
|
||||||
|
def main(event, context):
|
||||||
|
result = fib(28)
|
||||||
|
return "cpu-burn-py:" + str(result), 200
|
||||||
|
PYEOF
|
||||||
|
create_json "python" "$FN_CPU_PY" "${TMP}/cpu_py.py"
|
||||||
|
|
||||||
|
FN_CPU_NODE="t-cpu-node-${RUN_ID}"
|
||||||
|
cat > "${TMP}/cpu_node.js" << 'JSEOF'
|
||||||
|
function fib(n) {
|
||||||
|
if (n < 2) return n;
|
||||||
|
return fib(n-1) + fib(n-2);
|
||||||
|
}
|
||||||
|
module.exports = async function(ctx) {
|
||||||
|
return { status: 200, body: "cpu-burn-node:" + fib(35) };
|
||||||
|
};
|
||||||
|
JSEOF
|
||||||
|
create_json "nodejs" "$FN_CPU_NODE" "${TMP}/cpu_node.js"
|
||||||
|
|
||||||
|
FN_CPU_RUBY="t-cpu-ruby-${RUN_ID}"
|
||||||
|
cat > "${TMP}/cpu_ruby.rb" << 'RBEOF'
|
||||||
|
def fib(n)
|
||||||
|
return n if n < 2
|
||||||
|
fib(n-1) + fib(n-2)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handler(ctx)
|
||||||
|
{ statusCode: 200, body: "cpu-burn-ruby:#{fib(25)}" }
|
||||||
|
end
|
||||||
|
RBEOF
|
||||||
|
create_json "ruby" "$FN_CPU_RUBY" "${TMP}/cpu_ruby.rb"
|
||||||
|
|
||||||
|
FN_CPU_PHP="t-cpu-php-${RUN_ID}"
|
||||||
|
cat > "${TMP}/cpu_php.php" << 'PHPEOF'
|
||||||
|
<?php
|
||||||
|
function fib($n) {
|
||||||
|
if ($n < 2) return $n;
|
||||||
|
return fib($n-1) + fib($n-2);
|
||||||
|
}
|
||||||
|
function handler($ctx) {
|
||||||
|
$ctx["response"]->getBody()->write('cpu-burn-php:' . fib(28));
|
||||||
|
}
|
||||||
|
PHPEOF
|
||||||
|
create_json "php" "$FN_CPU_PHP" "${TMP}/cpu_php.php"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ">>> S2: invoke CPU-burn функций..."
|
||||||
|
for fn_tag in \
|
||||||
|
"${FN_CPU_PY}:cpu-burn-py:Python" \
|
||||||
|
"${FN_CPU_NODE}:cpu-burn-node:Node.js" \
|
||||||
|
"${FN_CPU_RUBY}:cpu-burn-ruby:Ruby" \
|
||||||
|
"${FN_CPU_PHP}:cpu-burn-php:PHP"; do
|
||||||
|
fn="${fn_tag%%:*}"; rest="${fn_tag#*:}"; tag="${rest%%:*}"; label="${rest#*:}"
|
||||||
|
(
|
||||||
|
if wait_invoke "$SUB" "$fn" "$tag" 18 6; then
|
||||||
|
echo "OK"
|
||||||
|
else
|
||||||
|
echo "FAIL"
|
||||||
|
fi
|
||||||
|
) > "${TMP}/s2-${fn}.out" &
|
||||||
|
done
|
||||||
|
wait
|
||||||
|
|
||||||
|
for fn_tag in \
|
||||||
|
"${FN_CPU_PY}:cpu-burn-py:Python" \
|
||||||
|
"${FN_CPU_NODE}:cpu-burn-node:Node.js" \
|
||||||
|
"${FN_CPU_RUBY}:cpu-burn-ruby:Ruby" \
|
||||||
|
"${FN_CPU_PHP}:cpu-burn-php:PHP"; do
|
||||||
|
fn="${fn_tag%%:*}"; rest="${fn_tag#*:}"; tag="${rest%%:*}"; label="${rest#*:}"
|
||||||
|
result=$(cat "${TMP}/s2-${fn}.out" 2>/dev/null || echo "FAIL")
|
||||||
|
if [ "$result" = "OK" ]; then
|
||||||
|
pass "S2 $label CPU BURN invoke → 200"
|
||||||
|
else
|
||||||
|
fail "S2 $label CPU BURN — не стабилизировался"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# SECTION 3: БОЛЬШОЙ ОТВЕТ — 50KB body
|
||||||
|
# Ищем: truncation, proxy memory issues, timeout на больших ответах
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
echo ""
|
||||||
|
echo "────────────────────────────────────────────────────────"
|
||||||
|
echo " S3: БОЛЬШОЙ ОТВЕТ — 50KB (Python + Node.js)"
|
||||||
|
echo "────────────────────────────────────────────────────────"
|
||||||
|
|
||||||
|
FN_RESP_PY="t-resp-py-${RUN_ID}"
|
||||||
|
cat > "${TMP}/resp_py.py" << 'PYEOF'
|
||||||
|
def main(event, context):
|
||||||
|
chunk = "FISSION-HEAVY-DATA-" * 100 # 1900 chars
|
||||||
|
data = (chunk * 27)[:50000] # ровно 50000 chars
|
||||||
|
return "bigresponse-py-ok:" + data[:20] + "..." + str(len(data)), 200
|
||||||
|
PYEOF
|
||||||
|
create_json "python" "$FN_RESP_PY" "${TMP}/resp_py.py"
|
||||||
|
|
||||||
|
FN_RESP_NODE="t-resp-node-${RUN_ID}"
|
||||||
|
cat > "${TMP}/resp_node.js" << 'JSEOF'
|
||||||
|
module.exports = async function(ctx) {
|
||||||
|
const chunk = "FISSION-HEAVY-DATA-".repeat(100);
|
||||||
|
const data = (chunk.repeat(27)).slice(0, 50000);
|
||||||
|
return { status: 200, body: "bigresponse-node-ok:" + data.slice(0,20) + "..." + data.length };
|
||||||
|
};
|
||||||
|
JSEOF
|
||||||
|
create_json "nodejs" "$FN_RESP_NODE" "${TMP}/resp_node.js"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ">>> S3: invoke и проверка размера ответа..."
|
||||||
|
for fn_tag in \
|
||||||
|
"${FN_RESP_PY}:bigresponse-py-ok:Python" \
|
||||||
|
"${FN_RESP_NODE}:bigresponse-node-ok:Node.js"; do
|
||||||
|
fn="${fn_tag%%:*}"; rest="${fn_tag#*:}"; tag="${rest%%:*}"; label="${rest#*:}"
|
||||||
|
if wait_invoke "$SUB" "$fn" "$tag" 15 6; then
|
||||||
|
# Проверяем реальный размер ответа
|
||||||
|
RESP=$(curl -s --max-time 60 -X POST "${BASE}/functions/${fn}/invoke" \
|
||||||
|
-H "X-Auth-Token: ${SUB}" -H "Content-Type: application/json" -d '{}')
|
||||||
|
RAW=$(json_field "$RESP" response_raw)
|
||||||
|
RAW_LEN=${#RAW}
|
||||||
|
echo " response_raw length = $RAW_LEN chars"
|
||||||
|
if [ "$RAW_LEN" -gt 40000 ]; then
|
||||||
|
pass "S3 $label BIG RESPONSE size=$RAW_LEN (>40000)"
|
||||||
|
else
|
||||||
|
fail "S3 $label BIG RESPONSE слишком мал: size=$RAW_LEN"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
fail "S3 $label BIG RESPONSE — invoke не стабилизировался"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# SECTION 4: АРХИВЫ ИЗ ZIP — создаём архивы на лету, все 4 языка
|
||||||
|
# Ищем: баги пути upload vs inline, entrypoint defaults, storagesvc PV
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
echo ""
|
||||||
|
echo "────────────────────────────────────────────────────────"
|
||||||
|
echo " S4: АРХИВ ИЗ ZIP (Python / Node.js / Ruby / PHP)"
|
||||||
|
echo "────────────────────────────────────────────────────────"
|
||||||
|
|
||||||
|
# --- Создаём ZIP-архивы на лету через python3 ---
|
||||||
|
|
||||||
|
# Python archive: main.py (entrypoint по умолчанию: main.main)
|
||||||
|
python3 - > "${TMP}/arc-py.zip" << 'EOF'
|
||||||
|
import zipfile, io, sys
|
||||||
|
buf = io.BytesIO()
|
||||||
|
code = """import json
|
||||||
|
def main(event, context):
|
||||||
|
return json.dumps({"tag": "archive-py-ok", "fib25": 75025}), 200
|
||||||
|
"""
|
||||||
|
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr('main.py', code)
|
||||||
|
sys.stdout.buffer.write(buf.getvalue())
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Node.js archive: main.js (entrypoint: main → файл main.js)
|
||||||
|
python3 - > "${TMP}/arc-node.zip" << 'EOF'
|
||||||
|
import zipfile, io, sys
|
||||||
|
buf = io.BytesIO()
|
||||||
|
code = """module.exports = async function(ctx) {
|
||||||
|
return { status: 200, body: JSON.stringify({ tag: "archive-node-ok", fib25: 75025 }) };
|
||||||
|
};
|
||||||
|
"""
|
||||||
|
pkg = '{"name":"heavy-node","version":"1.0.0"}'
|
||||||
|
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr('main.js', code)
|
||||||
|
zf.writestr('package.json', pkg)
|
||||||
|
sys.stdout.buffer.write(buf.getvalue())
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Ruby archive: handler.rb (entrypoint: handler)
|
||||||
|
python3 - > "${TMP}/arc-ruby.zip" << 'EOF'
|
||||||
|
import zipfile, io, sys
|
||||||
|
buf = io.BytesIO()
|
||||||
|
code = """require 'json'
|
||||||
|
def handler(ctx)
|
||||||
|
{ statusCode: 200, body: JSON.generate({ tag: "archive-ruby-ok", fib25: 75025 }) }
|
||||||
|
end
|
||||||
|
"""
|
||||||
|
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr('handler.rb', code)
|
||||||
|
sys.stdout.buffer.write(buf.getvalue())
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# PHP archive: main.php (entrypoint: main.php::handler)
|
||||||
|
python3 - > "${TMP}/arc-php.zip" << 'EOF'
|
||||||
|
import zipfile, io, sys
|
||||||
|
buf = io.BytesIO()
|
||||||
|
code = """<?php
|
||||||
|
function handler($ctx) {
|
||||||
|
$ctx[\"response\"]->getBody()->write(json_encode(['tag' => 'archive-php-ok', 'fib25' => 75025]));
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr('main.php', code)
|
||||||
|
sys.stdout.buffer.write(buf.getvalue())
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Загружаем архивы через multipart POST /functions
|
||||||
|
declare -A ARC_LANGS=( ["py"]="python" ["node"]="nodejs" ["ruby"]="ruby" ["php"]="php" )
|
||||||
|
declare -A ARC_TAGS=( ["py"]="archive-py-ok" ["node"]="archive-node-ok" ["ruby"]="archive-ruby-ok" ["php"]="archive-php-ok" )
|
||||||
|
|
||||||
|
for lang_short in py node ruby php; do
|
||||||
|
fn="t-arc-${lang_short}-${RUN_ID}"
|
||||||
|
reg "$fn"
|
||||||
|
lang="${ARC_LANGS[$lang_short]}"
|
||||||
|
arcfile="${TMP}/arc-${lang_short}.zip"
|
||||||
|
|
||||||
|
R=$(curl -s -w "\n%{http_code}" -X POST "${BASE}/functions" \
|
||||||
|
-H "X-Auth-Token: ${SUB}" \
|
||||||
|
-F "name=${fn}" \
|
||||||
|
-F "language=${lang}" \
|
||||||
|
-F "archive=@${arcfile};type=application/zip" 2>/dev/null)
|
||||||
|
CODE=$(echo "$R" | tail -1)
|
||||||
|
BODY=$(echo "$R" | head -n -1)
|
||||||
|
check_http "$CODE" "201" "S4 ${lang_short^^} ARCHIVE upload → 201"
|
||||||
|
if [ "$CODE" != "201" ]; then
|
||||||
|
echo " upload error: $(printf '%s' "$BODY" | head -c 200)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ">>> S4: invoke архивных функций — ждём cold start..."
|
||||||
|
for lang_short in py node ruby php; do
|
||||||
|
fn="t-arc-${lang_short}-${RUN_ID}"
|
||||||
|
tag="${ARC_TAGS[$lang_short]}"
|
||||||
|
(
|
||||||
|
if wait_invoke "$SUB" "$fn" "$tag" 20 6; then
|
||||||
|
echo "OK"
|
||||||
|
else
|
||||||
|
echo "FAIL"
|
||||||
|
fi
|
||||||
|
) > "${TMP}/s4-${fn}.out" &
|
||||||
|
done
|
||||||
|
wait
|
||||||
|
|
||||||
|
for lang_short in py node ruby php; do
|
||||||
|
fn="t-arc-${lang_short}-${RUN_ID}"
|
||||||
|
tag="${ARC_TAGS[$lang_short]}"
|
||||||
|
result=$(cat "${TMP}/s4-${fn}.out" 2>/dev/null || echo "FAIL")
|
||||||
|
if [ "$result" = "OK" ]; then
|
||||||
|
pass "S4 ${lang_short^^} ARCHIVE invoke → 200"
|
||||||
|
else
|
||||||
|
fail "S4 ${lang_short^^} ARCHIVE invoke — не стабилизировался"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# SECTION 5: БЫСТРЫЕ СЕРИИ — 12 последовательных invoke одной функции
|
||||||
|
# Ищем: poolmgr reuse bugs, утечки памяти, деградацию latency
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
echo ""
|
||||||
|
echo "────────────────────────────────────────────────────────"
|
||||||
|
echo " S5: БЫСТРЫЕ СЕРИИ — 12x invoke подряд (Python)"
|
||||||
|
echo "────────────────────────────────────────────────────────"
|
||||||
|
|
||||||
|
FN_RAPID="t-rapid-py-${RUN_ID}"
|
||||||
|
cat > "${TMP}/rapid_py.py" << 'PYEOF'
|
||||||
|
import time
|
||||||
|
_count = 0
|
||||||
|
def main(event, context):
|
||||||
|
global _count
|
||||||
|
_count += 1
|
||||||
|
return "rapid-ok:{}".format(_count), 200
|
||||||
|
PYEOF
|
||||||
|
create_json "python" "$FN_RAPID" "${TMP}/rapid_py.py"
|
||||||
|
|
||||||
|
echo ">>> Ждём cold start..."
|
||||||
|
if wait_invoke "$SUB" "$FN_RAPID" "rapid-ok" 15 5; then
|
||||||
|
echo ">>> S5: Запускаем 12 последовательных invoke..."
|
||||||
|
RAPID_OK=0
|
||||||
|
RAPID_FAIL=0
|
||||||
|
LATENCIES=()
|
||||||
|
for i in $(seq 1 12); do
|
||||||
|
T_START=$(date +%s%3N)
|
||||||
|
RESP=$(curl -s --max-time 20 -X POST "${BASE}/functions/${FN_RAPID}/invoke" \
|
||||||
|
-H "X-Auth-Token: ${SUB}" -H "Content-Type: application/json" -d '{}')
|
||||||
|
T_END=$(date +%s%3N)
|
||||||
|
LAT=$(( T_END - T_START ))
|
||||||
|
STATUS=$(json_field "$RESP" status)
|
||||||
|
RAW=$(json_field "$RESP" response_raw)
|
||||||
|
echo " invoke ${i}/12: status=$STATUS raw=$(printf '%s' "$RAW" | head -c 40) latency=${LAT}ms"
|
||||||
|
if [ "$STATUS" = "200" ]; then
|
||||||
|
RAPID_OK=$((RAPID_OK+1))
|
||||||
|
LATENCIES+=("$LAT")
|
||||||
|
else
|
||||||
|
RAPID_FAIL=$((RAPID_FAIL+1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "$RAPID_FAIL" -eq 0 ] && pass "S5 RAPID FIRE: 12/12 invoke → 200" \
|
||||||
|
|| fail "S5 RAPID FIRE: ${RAPID_FAIL}/12 упали"
|
||||||
|
# Статистика latency
|
||||||
|
if [ "${#LATENCIES[@]}" -gt 0 ]; then
|
||||||
|
LAT_MIN="${LATENCIES[0]}"; LAT_MAX="${LATENCIES[0]}"
|
||||||
|
for lat in "${LATENCIES[@]}"; do
|
||||||
|
[ "$lat" -lt "$LAT_MIN" ] && LAT_MIN="$lat"
|
||||||
|
[ "$lat" -gt "$LAT_MAX" ] && LAT_MAX="$lat"
|
||||||
|
done
|
||||||
|
echo " latency min=${LAT_MIN}ms max=${LAT_MAX}ms"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
fail "S5 RAPID FIRE — cold start не прошёл, тест пропущен"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# SECTION 6: ПАРАЛЛЕЛЬНЫЕ ВЫЗОВЫ — 5 параллельных invoke на каждый язык
|
||||||
|
# Ищем: race conditions, конфликты при pod scheduling, deadlocks
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
echo ""
|
||||||
|
echo "────────────────────────────────────────────────────────"
|
||||||
|
echo " S6: ПАРАЛЛЕЛЬНЫЕ ВЫЗОВЫ — 5 одновременных invoke × 4 языка"
|
||||||
|
echo "────────────────────────────────────────────────────────"
|
||||||
|
PARALLEL_N=5
|
||||||
|
|
||||||
|
# Используем уже тёплые функции из S2 (CPU burn — уже invoke'd)
|
||||||
|
declare -A PAR_FNS=( ["Python"]="$FN_CPU_PY" ["Node.js"]="$FN_CPU_NODE" ["Ruby"]="$FN_CPU_RUBY" ["PHP"]="$FN_CPU_PHP" )
|
||||||
|
declare -A PAR_TAGS=( ["Python"]="cpu-burn-py" ["Node.js"]="cpu-burn-node" ["Ruby"]="cpu-burn-ruby" ["PHP"]="cpu-burn-php" )
|
||||||
|
|
||||||
|
for lang in "Python" "Node.js" "Ruby" "PHP"; do
|
||||||
|
fn="${PAR_FNS[$lang]}"
|
||||||
|
tag="${PAR_TAGS[$lang]}"
|
||||||
|
echo ""
|
||||||
|
echo ">>> S6 $lang: $PARALLEL_N параллельных invoke..."
|
||||||
|
for i in $(seq 1 "$PARALLEL_N"); do
|
||||||
|
(
|
||||||
|
RESP=$(curl -s --max-time 60 -X POST "${BASE}/functions/${fn}/invoke" \
|
||||||
|
-H "X-Auth-Token: ${SUB}" -H "Content-Type: application/json" -d '{}')
|
||||||
|
STATUS=$(json_field "$RESP" status)
|
||||||
|
RAW=$(json_field "$RESP" response_raw)
|
||||||
|
if [ "$STATUS" = "200" ] && printf '%s' "$RAW" | grep -qF "$tag"; then
|
||||||
|
echo "OK" > "${TMP}/par-${lang}-${i}.out"
|
||||||
|
else
|
||||||
|
echo "FAIL:status=${STATUS}" > "${TMP}/par-${lang}-${i}.out"
|
||||||
|
fi
|
||||||
|
) &
|
||||||
|
done
|
||||||
|
wait
|
||||||
|
|
||||||
|
PAR_OK=0; PAR_FAIL=0
|
||||||
|
for i in $(seq 1 "$PARALLEL_N"); do
|
||||||
|
result=$(cat "${TMP}/par-${lang}-${i}.out" 2>/dev/null || echo "FAIL:no-file")
|
||||||
|
if [ "$result" = "OK" ]; then
|
||||||
|
PAR_OK=$((PAR_OK+1))
|
||||||
|
else
|
||||||
|
PAR_FAIL=$((PAR_FAIL+1))
|
||||||
|
echo " invoke $i: $result"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "$PAR_FAIL" -eq 0 ] \
|
||||||
|
&& pass "S6 $lang PARALLEL: ${PARALLEL_N}/${PARALLEL_N} invoke → 200" \
|
||||||
|
|| fail "S6 $lang PARALLEL: ${PAR_FAIL}/${PARALLEL_N} упали"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# SECTION 7: МЕДЛЕННЫЕ ФУНКЦИИ — sleep 6s, Python + Node.js
|
||||||
|
# Ищем: router timeout, poolmgr idle eviction, 504 под нагрузкой
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
echo ""
|
||||||
|
echo "────────────────────────────────────────────────────────"
|
||||||
|
echo " S7: МЕДЛЕННЫЕ ФУНКЦИИ — sleep(6s) × Python + Node.js"
|
||||||
|
echo "────────────────────────────────────────────────────────"
|
||||||
|
|
||||||
|
FN_SLOW_PY="t-slow-py-${RUN_ID}"
|
||||||
|
cat > "${TMP}/slow_py.py" << 'PYEOF'
|
||||||
|
import time
|
||||||
|
def main(event, context):
|
||||||
|
time.sleep(6)
|
||||||
|
return "slow-py-ok", 200
|
||||||
|
PYEOF
|
||||||
|
create_json "python" "$FN_SLOW_PY" "${TMP}/slow_py.py"
|
||||||
|
|
||||||
|
FN_SLOW_NODE="t-slow-node-${RUN_ID}"
|
||||||
|
cat > "${TMP}/slow_node.js" << 'JSEOF'
|
||||||
|
module.exports = async function(ctx) {
|
||||||
|
await new Promise(r => setTimeout(r, 6000));
|
||||||
|
return { status: 200, body: "slow-node-ok" };
|
||||||
|
};
|
||||||
|
JSEOF
|
||||||
|
create_json "nodejs" "$FN_SLOW_NODE" "${TMP}/slow_node.js"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ">>> S7: invoke (ждём cold start + 6s sleep)..."
|
||||||
|
for fn_tag in \
|
||||||
|
"${FN_SLOW_PY}:slow-py-ok:Python" \
|
||||||
|
"${FN_SLOW_NODE}:slow-node-ok:Node.js"; do
|
||||||
|
fn="${fn_tag%%:*}"; rest="${fn_tag#*:}"; tag="${rest%%:*}"; label="${rest#*:}"
|
||||||
|
T_START=$(date +%s%3N)
|
||||||
|
if wait_invoke "$SUB" "$fn" "$tag" 15 8; then
|
||||||
|
T_END=$(date +%s%3N)
|
||||||
|
LAT=$(( T_END - T_START ))
|
||||||
|
pass "S7 $label SLOW(6s) → 200 за ~${LAT}ms total"
|
||||||
|
# Повторный invoke (warm pod): должно занять ≥6s
|
||||||
|
T2=$(date +%s%3N)
|
||||||
|
RESP=$(curl -s --max-time 30 -X POST "${BASE}/functions/${fn}/invoke" \
|
||||||
|
-H "X-Auth-Token: ${SUB}" -H "Content-Type: application/json" -d '{}')
|
||||||
|
T2E=$(date +%s%3N)
|
||||||
|
LAT2=$(( T2E - T2 ))
|
||||||
|
STATUS=$(json_field "$RESP" status)
|
||||||
|
RAW=$(json_field "$RESP" response_raw)
|
||||||
|
echo " warm invoke: status=$STATUS latency=${LAT2}ms raw=$(printf '%s' "$RAW" | head -c 40)"
|
||||||
|
if [ "$STATUS" = "200" ] && [ "$LAT2" -ge 5000 ]; then
|
||||||
|
pass "S7 $label SLOW warm invoke → 200 latency=${LAT2}ms (≥5s)"
|
||||||
|
elif [ "$STATUS" = "200" ]; then
|
||||||
|
fail "S7 $label SLOW warm слишком быстро: ${LAT2}ms — sleep не сработал?"
|
||||||
|
else
|
||||||
|
fail "S7 $label SLOW warm invoke → status=$STATUS"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
fail "S7 $label SLOW — cold start не прошёл"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# SECTION 8: ДАВЛЕНИЕ ПО ПАМЯТИ — аллокация 20MB, Python + Node.js
|
||||||
|
# Ищем: OOM kill на поде, зависание, некорректный ответ
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
echo ""
|
||||||
|
echo "────────────────────────────────────────────────────────"
|
||||||
|
echo " S8: ПАМЯТЬ — аллокация 20MB (Python + Node.js)"
|
||||||
|
echo "────────────────────────────────────────────────────────"
|
||||||
|
|
||||||
|
FN_MEM_PY="t-mem-py-${RUN_ID}"
|
||||||
|
cat > "${TMP}/mem_py.py" << 'PYEOF'
|
||||||
|
def main(event, context):
|
||||||
|
# Аллоцируем 20MB и суммируем (чтобы не оптимизировалось)
|
||||||
|
data = bytearray(20 * 1024 * 1024)
|
||||||
|
for i in range(0, len(data), 4096):
|
||||||
|
data[i] = i % 256
|
||||||
|
checksum = sum(data[i] for i in range(0, len(data), 4096)) % 65536
|
||||||
|
return "mem-py-ok:{}:{}".format(len(data), checksum), 200
|
||||||
|
PYEOF
|
||||||
|
create_json "python" "$FN_MEM_PY" "${TMP}/mem_py.py"
|
||||||
|
|
||||||
|
FN_MEM_NODE="t-mem-node-${RUN_ID}"
|
||||||
|
cat > "${TMP}/mem_node.js" << 'JSEOF'
|
||||||
|
module.exports = async function(ctx) {
|
||||||
|
const size = 20 * 1024 * 1024;
|
||||||
|
const buf = Buffer.alloc(size);
|
||||||
|
for (let i = 0; i < size; i += 4096) buf[i] = i % 256;
|
||||||
|
let checksum = 0;
|
||||||
|
for (let i = 0; i < size; i += 4096) checksum = (checksum + buf[i]) % 65536;
|
||||||
|
return { status: 200, body: `mem-node-ok:${size}:${checksum}` };
|
||||||
|
};
|
||||||
|
JSEOF
|
||||||
|
create_json "nodejs" "$FN_MEM_NODE" "${TMP}/mem_node.js"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ">>> S8: invoke (20MB аллокация)..."
|
||||||
|
for fn_tag in \
|
||||||
|
"${FN_MEM_PY}:mem-py-ok:Python" \
|
||||||
|
"${FN_MEM_NODE}:mem-node-ok:Node.js"; do
|
||||||
|
fn="${fn_tag%%:*}"; rest="${fn_tag#*:}"; tag="${rest%%:*}"; label="${rest#*:}"
|
||||||
|
if wait_invoke "$SUB" "$fn" "$tag" 18 6; then
|
||||||
|
pass "S8 $label MEMORY(20MB) → 200"
|
||||||
|
# Проверяем что данные в ответе правильные
|
||||||
|
RESP=$(curl -s --max-time 30 -X POST "${BASE}/functions/${fn}/invoke" \
|
||||||
|
-H "X-Auth-Token: ${SUB}" -H "Content-Type: application/json" -d '{}')
|
||||||
|
RAW=$(json_field "$RESP" response_raw)
|
||||||
|
if printf '%s' "$RAW" | grep -qE "$tag:[0-9]+:[0-9]+"; then
|
||||||
|
pass "S8 $label MEMORY ответ содержит checksum: $(printf '%s' "$RAW" | head -c 60)"
|
||||||
|
else
|
||||||
|
fail "S8 $label MEMORY ответ некорректен: $(printf '%s' "$RAW" | head -c 60)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
fail "S8 $label MEMORY(20MB) — не вернулась (OOM kill?)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
print_summary
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# test_layer1.sh — простой тест пропатченного Fission NSWatcher (Layer 1 only).
|
|
||||||
# Без консоли. Только kubectl + fission CLI.
|
|
||||||
#
|
|
||||||
# Что проверяем:
|
|
||||||
# 1. Создаём NS с меткой fission.io/managed=true
|
|
||||||
# 2. Executor регистрирует его через NSWatcher (лог)
|
|
||||||
# 3. Создаём Python env + function в новом NS
|
|
||||||
# 4. Вызываем функцию — должна ответить 200
|
|
||||||
#
|
|
||||||
# Запуск: bash ~/terra/fission/test_layer1.sh
|
|
||||||
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
NS="l1-test-$(date +%s | tail -c 6)"
|
|
||||||
PASS=0; FAIL=0
|
|
||||||
|
|
||||||
green() { echo -e "\033[32m PASS: $1\033[0m"; PASS=$((PASS+1)); }
|
|
||||||
red() { echo -e "\033[31m FAIL: $1\033[0m"; FAIL=$((FAIL+1)); }
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "========================================"
|
|
||||||
echo " Layer 1 NSWatcher — простой тест"
|
|
||||||
echo " NS: $NS"
|
|
||||||
echo "========================================"
|
|
||||||
|
|
||||||
# ── 1. Создаём NS с меткой ────────────────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo ">>> [1/5] Создаём NS $NS с меткой fission.io/managed=true..."
|
|
||||||
kubectl create ns "$NS"
|
|
||||||
kubectl label ns "$NS" fission.io/managed=true --overwrite
|
|
||||||
|
|
||||||
kubectl get ns "$NS" --show-labels | grep "fission.io/managed"
|
|
||||||
if [ $? -eq 0 ]; then
|
|
||||||
green "NS создан с меткой"
|
|
||||||
else
|
|
||||||
red "Метка не применилась"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 2. Ждём регистрации в executor ────────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo ">>> [2/5] Ждём регистрации NS в executor (до 15 сек)..."
|
|
||||||
|
|
||||||
DEADLINE=$((SECONDS + 15))
|
|
||||||
REGISTERED=false
|
|
||||||
while [ $SECONDS -lt $DEADLINE ]; do
|
|
||||||
if kubectl logs -n fission deployment/executor 2>/dev/null | grep "registered namespace" | grep -q "$NS"; then
|
|
||||||
REGISTERED=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$REGISTERED" = "true" ]; then
|
|
||||||
green "Executor зарегистрировал NS $NS"
|
|
||||||
else
|
|
||||||
red "Executor НЕ зарегистрировал NS за 15 сек"
|
|
||||||
echo " Лог executor (последние строки про namespace):"
|
|
||||||
kubectl logs -n fission deployment/executor 2>/dev/null | grep "namespace" | tail -5
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 3. Создаём environment в новом NS ─────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo ">>> [3/5] Создаём Python environment в NS $NS..."
|
|
||||||
|
|
||||||
fission env create \
|
|
||||||
--name py-env \
|
|
||||||
--namespace "$NS" \
|
|
||||||
--image ghcr.io/fission/python-env:latest \
|
|
||||||
--poolsize 1 2>&1
|
|
||||||
|
|
||||||
if kubectl get environment py-env -n "$NS" &>/dev/null; then
|
|
||||||
green "Environment py-env создан в $NS"
|
|
||||||
else
|
|
||||||
red "Environment не создался"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 4. Создаём функцию ────────────────────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo ">>> [4/5] Создаём функцию hello..."
|
|
||||||
|
|
||||||
cat > /tmp/hello.py << 'EOF'
|
|
||||||
def main():
|
|
||||||
return "hello from layer1"
|
|
||||||
EOF
|
|
||||||
|
|
||||||
fission fn create \
|
|
||||||
--name hello \
|
|
||||||
--namespace "$NS" \
|
|
||||||
--env py-env \
|
|
||||||
--code /tmp/hello.py 2>&1
|
|
||||||
|
|
||||||
fission httptrigger create \
|
|
||||||
--name hello-route \
|
|
||||||
--namespace "$NS" \
|
|
||||||
--function hello \
|
|
||||||
--url "/${NS}/hello" \
|
|
||||||
--method GET 2>&1
|
|
||||||
|
|
||||||
if kubectl get httptrigger hello-route -n "$NS" &>/dev/null; then
|
|
||||||
green "Function + HTTPTrigger созданы"
|
|
||||||
else
|
|
||||||
red "HTTPTrigger не создался"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 5. Вызываем функцию через внутренний router с JWT ────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo ">>> [5/5] Вызываем функцию через internal router + JWT (cold start до 180 сек)..."
|
|
||||||
|
|
||||||
ROUTER_INT="http://router.fission.svc.cluster.local"
|
|
||||||
DEADLINE=$((SECONDS + 180))
|
|
||||||
FN_OK=false
|
|
||||||
|
|
||||||
while [ $SECONDS -lt $DEADLINE ]; do
|
|
||||||
RESULT=$(kubectl run fn-probe-$$ --rm -i --restart=Never \
|
|
||||||
--image=curlimages/curl:8.7.1 \
|
|
||||||
--namespace=fission \
|
|
||||||
-- sh -c "
|
|
||||||
TOKEN=\$(curl -sf --max-time 15 -X POST ${ROUTER_INT}/auth/login \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{\"username\":\"admin\",\"password\":\"7XG1lSg0EFqPLE4pf3He\"}' \
|
|
||||||
| grep -o '\"accesstoken\":\"[^\"]*' | cut -d'\"' -f4)
|
|
||||||
if [ -z \"\$TOKEN\" ]; then echo 'LOGIN_FAIL|||000'; exit 0; fi
|
|
||||||
curl -s -w '|||%{http_code}' --max-time 90 \
|
|
||||||
-H \"Authorization: Bearer \$TOKEN\" \
|
|
||||||
${ROUTER_INT}/${NS}/hello
|
|
||||||
" 2>/dev/null | grep "|||" || echo "|||000")
|
|
||||||
CODE=$(echo "$RESULT" | grep -o '|||[0-9]*' | tr -d '|||')
|
|
||||||
BODY=$(echo "$RESULT" | sed 's/|||[0-9]*$//')
|
|
||||||
echo " HTTP $CODE — $(echo "$BODY" | head -c 80)"
|
|
||||||
[ "$CODE" = "200" ] && FN_OK=true && break
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$FN_OK" = "true" ]; then
|
|
||||||
green "Функция ответила 200: $BODY"
|
|
||||||
else
|
|
||||||
red "Функция не ответила 200 (timeout)"
|
|
||||||
echo " Логи router (last 3):"
|
|
||||||
kubectl logs -n fission deployment/router 2>/dev/null | tail -3
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Cleanup ───────────────────────────────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo ">>> Cleanup..."
|
|
||||||
fission httptrigger delete --name hello-route --namespace "$NS" 2>/dev/null || true
|
|
||||||
fission fn delete --name hello --namespace "$NS" 2>/dev/null || true
|
|
||||||
fission env delete --name py-env --namespace "$NS" 2>/dev/null || true
|
|
||||||
kubectl delete ns "$NS" --wait=false 2>/dev/null || true
|
|
||||||
echo " Готово."
|
|
||||||
|
|
||||||
# ── Итог ─────────────────────────────────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo "========================================"
|
|
||||||
echo " ИТОГ: PASS=$PASS FAIL=$FAIL"
|
|
||||||
echo "========================================"
|
|
||||||
[ $FAIL -eq 0 ] && exit 0 || exit 1
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Dockerfile для sqs-consumer
|
||||||
|
# Назначение: поллит SQS-совместимую очередь (shared-sqs) и вызывает Fission-функцию.
|
||||||
|
# Образ: naeel/sqs-consumer:v1.0
|
||||||
|
# 2026-05-11
|
||||||
|
|
||||||
|
FROM golang:1.22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY go.mod ./
|
||||||
|
COPY main.go ./
|
||||||
|
RUN go mod tidy && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o sqs-consumer .
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static:nonroot
|
||||||
|
WORKDIR /
|
||||||
|
COPY --from=builder /app/sqs-consumer .
|
||||||
|
USER 65532:65532
|
||||||
|
ENTRYPOINT ["/sqs-consumer"]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
module sqs-consumer
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.26.1
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.27.11
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.11
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sqs v1.31.4
|
||||||
|
)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
github.com/aws/aws-sdk-go-v2 v1.26.1 h1:5554eUqIYVWpU0YmeeYZ0wU64H2VLBs8TlhRB2L+EkA=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.26.1/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.27.11 h1:f47rANd2LQEYHda2ddSCKYId18/8BhSRM4BULGmfgNA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.27.11/go.mod h1:SMsV78RIOYdve1vf36z8LmnszlRWkwMQtomCAI0/mIE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.11 h1:YuIB1dJNf1Re822rriUOTxopaHHvIq0l/pX3fwO+Tzs=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.11/go.mod h1:AQtFPsDH9bI2O+71anW6EKL+NcD7LG3dpKGMV4SShgo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 h1:FVJ0r5XTHSmIHJV6KuDmdYhEpvlHpiSd38RQWhut5J4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1/go.mod h1:zusuAeqezXzAB24LGuzuekqMAEgWkVYukBec3kr3jUg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 h1:aw39xVGeRWlWx9EzGVnhOR4yOjQDHPQ6o6NmBlscyQg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5/go.mod h1:FSaRudD0dXiMPK2UjknVwwTYyZMRsHv3TtkabsZih5I=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 h1:PG1F3OD1szkuQPzDw3CIQsRIrtTlUC3lP84taWzHlq0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5/go.mod h1:jU1li6RFryMz+so64PpKtudI+QzbKoIEivqdf6LNpOc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 h1:Ji0DY1xUsUr3I8cHps0G+XM3WWU16lP6yG8qu1GAZAs=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2/go.mod h1:5CsjAbs3NlGQyZNFACh+zztPDI7fU6eW9QsxjfnuBKg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 h1:ogRAwT1/gxJBcSWDMZlgyFUM962F51A5CRhDLbxLdmo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7/go.mod h1:YCsIZhXfRPLFFCl5xxY+1T9RKzOKjCut+28JSX2DnAk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sqs v1.31.4 h1:mE2ysZMEeQ3ulHWs4mmc4fZEhOfeY1o6QXAfDqjbSgw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sqs v1.31.4/go.mod h1:lCN2yKnj+Sp9F6UzpoPPTir+tSaC9Jwf6LcmTqnXFZw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.20.5 h1:vN8hEbpRnL7+Hopy9dzmRle1xmDc7o8tmY0klsr175w=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.20.5/go.mod h1:qGzynb/msuZIE8I75DVRCUXw3o3ZyBmUvMwQ2t/BrGM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4 h1:Jux+gDDyi1Lruk+KHF91tK2KCuY61kzoCpvtvJJBtOE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4/go.mod h1:mUYPBhaF2lGiukDEjJX2BLRRKTmoUSitGDUgM4tRxak=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 h1:cwIxeBttqPN3qkaAjcEcsh8NYr8n2HZPkcKgPAi1phU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.28.6/go.mod h1:FZf1/nKNEkHdGGJP/cI2MoIMquumuRK6ol3QQJNDxmw=
|
||||||
|
github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q=
|
||||||
|
github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
// sqs-consumer — SQS poller → Fission function invoker
|
||||||
|
//
|
||||||
|
// Env vars:
|
||||||
|
// SQS_ENDPOINT — URL SQS сервиса
|
||||||
|
// SQS_QUEUE_NAME — имя очереди (обязательно)
|
||||||
|
// SQS_ACCESS_KEY — AccessKeyId тенанта
|
||||||
|
// SQS_SECRET_KEY — SecretAccessKey тенанта
|
||||||
|
// SQS_REGION — регион (default: us-east-1)
|
||||||
|
// FUNCTION_URL — полный URL функции
|
||||||
|
// ROUTER_USERNAME — логин для /auth/login роутера (optional)
|
||||||
|
// ROUTER_PASSWORD — пароль для /auth/login роутера (optional)
|
||||||
|
// ROUTER_LOGIN_URL — URL /auth/login (default: выводится из FUNCTION_URL)
|
||||||
|
// AUTH_TOKEN — статичный Bearer токен (если ROUTER_USERNAME не задан)
|
||||||
|
// POLL_INTERVAL — интервал поллинга в секундах (default: 5)
|
||||||
|
// MAX_MESSAGES — макс. сообщений за раз (default: 1)
|
||||||
|
// MAX_RETRIES — попыток вызова функции перед skip (default: 3)
|
||||||
|
//
|
||||||
|
// 2026-05-12
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/sqs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tokenManager управляет JWT токеном с автообновлением.
|
||||||
|
type tokenManager struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
client *http.Client
|
||||||
|
loginURL string
|
||||||
|
username string
|
||||||
|
password string
|
||||||
|
staticToken string
|
||||||
|
cached string
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTokenManager(client *http.Client, functionURL, loginURL, username, password, staticToken string) *tokenManager {
|
||||||
|
if loginURL == "" && username != "" {
|
||||||
|
if i := strings.Index(functionURL, "://"); i >= 0 {
|
||||||
|
rest := functionURL[i+3:]
|
||||||
|
if j := strings.Index(rest, "/"); j >= 0 {
|
||||||
|
loginURL = functionURL[:i+3] + rest[:j] + "/auth/login"
|
||||||
|
} else {
|
||||||
|
loginURL = functionURL + "/auth/login"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &tokenManager{
|
||||||
|
client: client,
|
||||||
|
loginURL: loginURL,
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
staticToken: staticToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tm *tokenManager) getToken(log *slog.Logger) string {
|
||||||
|
tm.mu.Lock()
|
||||||
|
defer tm.mu.Unlock()
|
||||||
|
|
||||||
|
if tm.username == "" {
|
||||||
|
return tm.staticToken
|
||||||
|
}
|
||||||
|
if tm.cached != "" && time.Now().Add(30*time.Second).Before(tm.expiresAt) {
|
||||||
|
return tm.cached
|
||||||
|
}
|
||||||
|
token, exp := tm.login(log)
|
||||||
|
if token != "" {
|
||||||
|
tm.cached = token
|
||||||
|
tm.expiresAt = exp
|
||||||
|
log.Info("router token refreshed", "expiresAt", exp)
|
||||||
|
}
|
||||||
|
return tm.cached
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tm *tokenManager) login(log *slog.Logger) (string, time.Time) {
|
||||||
|
body, _ := json.Marshal(map[string]string{"username": tm.username, "password": tm.password})
|
||||||
|
resp, err := tm.client.Post(tm.loginURL, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
log.Error("router login failed", "err", err)
|
||||||
|
return "", time.Time{}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
log.Error("router login non-2xx", "status", resp.StatusCode)
|
||||||
|
return "", time.Time{}
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
AccessToken string `json:"accesstoken"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || result.AccessToken == "" {
|
||||||
|
log.Error("router login decode failed", "err", err)
|
||||||
|
return "", time.Time{}
|
||||||
|
}
|
||||||
|
return result.AccessToken, time.Now().Add(5 * time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tm *tokenManager) invalidate() {
|
||||||
|
tm.mu.Lock()
|
||||||
|
defer tm.mu.Unlock()
|
||||||
|
tm.cached = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
|
||||||
|
endpoint := getenv("SQS_ENDPOINT", "http://shared-sqs.shared-sqs.svc.cluster.local:4100")
|
||||||
|
queueName := mustenv("SQS_QUEUE_NAME")
|
||||||
|
accessKey := mustenv("SQS_ACCESS_KEY")
|
||||||
|
secretKey := mustenv("SQS_SECRET_KEY")
|
||||||
|
region := getenv("SQS_REGION", "us-east-1")
|
||||||
|
functionURL := mustenv("FUNCTION_URL")
|
||||||
|
routerUser := getenv("ROUTER_USERNAME", "")
|
||||||
|
routerPass := getenv("ROUTER_PASSWORD", "")
|
||||||
|
routerLoginURL := getenv("ROUTER_LOGIN_URL", "")
|
||||||
|
staticToken := getenv("AUTH_TOKEN", "")
|
||||||
|
pollSec := parseInt(getenv("POLL_INTERVAL", "5"), 5)
|
||||||
|
maxMsg := int32(parseInt(getenv("MAX_MESSAGES", "1"), 1))
|
||||||
|
maxRetries := parseInt(getenv("MAX_RETRIES", "3"), 3)
|
||||||
|
|
||||||
|
customResolver := aws.EndpointResolverWithOptionsFunc(
|
||||||
|
func(service, reg string, opts ...interface{}) (aws.Endpoint, error) {
|
||||||
|
return aws.Endpoint{URL: endpoint, HostnameImmutable: true}, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
cfg, err := awsconfig.LoadDefaultConfig(context.Background(),
|
||||||
|
awsconfig.WithRegion(region),
|
||||||
|
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
|
||||||
|
awsconfig.WithEndpointResolverWithOptions(customResolver),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("failed to create AWS config", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := sqs.NewFromConfig(cfg)
|
||||||
|
ctx := context.Background()
|
||||||
|
urlResult, err := client.GetQueueUrl(ctx, &sqs.GetQueueUrlInput{QueueName: aws.String(queueName)})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetQueueUrl failed", "queue", queueName, "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
queueURL := aws.ToString(urlResult.QueueUrl)
|
||||||
|
log.Info("sqs-consumer started", "queue", queueName, "endpoint", endpoint, "functionURL", functionURL)
|
||||||
|
|
||||||
|
httpClient := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
tm := newTokenManager(httpClient, functionURL, routerLoginURL, routerUser, routerPass, staticToken)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(time.Duration(pollSec) * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
poll(ctx, log, client, httpClient, tm, queueURL, functionURL, maxMsg, maxRetries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func poll(ctx context.Context, log *slog.Logger, sqsClient *sqs.Client, httpClient *http.Client,
|
||||||
|
tm *tokenManager, queueURL, functionURL string, maxMsg int32, maxRetries int) {
|
||||||
|
|
||||||
|
result, err := sqsClient.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
|
||||||
|
QueueUrl: aws.String(queueURL),
|
||||||
|
MaxNumberOfMessages: maxMsg,
|
||||||
|
WaitTimeSeconds: 5,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("SQS ReceiveMessage failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, msg := range result.Messages {
|
||||||
|
body := aws.ToString(msg.Body)
|
||||||
|
receipt := aws.ToString(msg.ReceiptHandle)
|
||||||
|
if invokeWithRetry(log, httpClient, tm, functionURL, body, maxRetries) {
|
||||||
|
if _, err := sqsClient.DeleteMessage(ctx, &sqs.DeleteMessageInput{
|
||||||
|
QueueUrl: aws.String(queueURL),
|
||||||
|
ReceiptHandle: aws.String(receipt),
|
||||||
|
}); err != nil {
|
||||||
|
log.Error("DeleteMessage failed", "err", err)
|
||||||
|
} else {
|
||||||
|
log.Info("message processed", "msgId", aws.ToString(msg.MessageId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func invokeWithRetry(log *slog.Logger, client *http.Client, tm *tokenManager, url, body string, maxRetries int) bool {
|
||||||
|
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||||
|
token := tm.getToken(log)
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("function invoke error", "attempt", attempt, "err", err)
|
||||||
|
time.Sleep(time.Duration(attempt) * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
log.Warn("got 401, refreshing token", "attempt", attempt)
|
||||||
|
tm.invalidate()
|
||||||
|
} else {
|
||||||
|
log.Warn("function returned non-2xx", "attempt", attempt, "status", resp.StatusCode)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Duration(attempt) * time.Second)
|
||||||
|
}
|
||||||
|
log.Error("all retries exhausted, skipping message", "url", url)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenv(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustenv(key string) string {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "ERROR: env var %s is required\n", key)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt(s string, def int) int {
|
||||||
|
n, err := strconv.Atoi(s)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -4,7 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
appsv1 "k8s.io/api/apps/v1"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
k8sresource "k8s.io/apimachinery/pkg/api/resource"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
@@ -38,6 +41,28 @@ var httpTriggerGVR = schema.GroupVersionResource{
|
|||||||
Resource: "httptriggers",
|
Resource: "httptriggers",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var timeTriggerGVR = schema.GroupVersionResource{
|
||||||
|
Group: "fission.io",
|
||||||
|
Version: "v1",
|
||||||
|
Resource: "timetriggers",
|
||||||
|
}
|
||||||
|
|
||||||
|
var iotDeviceGVR = schema.GroupVersionResource{
|
||||||
|
Group: "iot.kube5s.ru",
|
||||||
|
Version: "v1alpha1",
|
||||||
|
Resource: "iotdevices",
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
sqsConsumerImage = "naeel/sqs-consumer:v1.0"
|
||||||
|
sqsDefaultEndpoint = "http://shared-sqs.shared-sqs.svc.cluster.local:4100"
|
||||||
|
fissionRouterBase = "http://router.fission.svc.cluster.local"
|
||||||
|
mqManagedByLabel = "app.kubernetes.io/managed-by"
|
||||||
|
mqManagedByVal = "fission-terraform"
|
||||||
|
mqComponentLabel = "component"
|
||||||
|
mqComponentVal = "mq-trigger"
|
||||||
|
)
|
||||||
|
|
||||||
// Client хранит клиентов Kubernetes API для работы с CRD Fission.
|
// Client хранит клиентов Kubernetes API для работы с CRD Fission.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
DynClient dynamic.Interface
|
DynClient dynamic.Interface
|
||||||
@@ -253,3 +278,242 @@ func (c *Client) DeleteHTTPTrigger(ctx context.Context, namespace, name string)
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── MQ Trigger (K8s Deployment + Secret) ────────────────────────────
|
||||||
|
|
||||||
|
func mqSecretName(name string) string { return "sqs-mq-" + name }
|
||||||
|
func mqDeployName(name string) string { return "mq-" + name }
|
||||||
|
|
||||||
|
// MQTriggerInfo — данные MQ trigger, считанные из K8s.
|
||||||
|
type MQTriggerInfo struct {
|
||||||
|
Function string
|
||||||
|
Queue string
|
||||||
|
SQSEndpoint string
|
||||||
|
DeployUID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMQTrigger создаёт Secret + Deployment для SQS consumer.
|
||||||
|
func (c *Client) CreateMQTrigger(ctx context.Context, name, ns, functionName, queue, sqsEndpoint, accessKey, secretKey string) (*MQTriggerInfo, error) {
|
||||||
|
if sqsEndpoint == "" {
|
||||||
|
sqsEndpoint = sqsDefaultEndpoint
|
||||||
|
}
|
||||||
|
functionURL := fissionRouterBase + "/" + functionName
|
||||||
|
secretName := mqSecretName(name)
|
||||||
|
deployName := mqDeployName(name)
|
||||||
|
|
||||||
|
labels := map[string]string{
|
||||||
|
mqManagedByLabel: mqManagedByVal,
|
||||||
|
mqComponentLabel: mqComponentVal,
|
||||||
|
"mq-trigger-name": name,
|
||||||
|
}
|
||||||
|
|
||||||
|
secret := &corev1.Secret{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: ns, Labels: labels},
|
||||||
|
StringData: map[string]string{
|
||||||
|
"SQS_ACCESS_KEY": accessKey,
|
||||||
|
"SQS_SECRET_KEY": secretKey,
|
||||||
|
"SQS_ENDPOINT": sqsEndpoint,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if _, err := c.K8sClient.CoreV1().Secrets(ns).Create(ctx, secret, metav1.CreateOptions{}); err != nil {
|
||||||
|
return nil, fmt.Errorf("create mq secret %q: %w", secretName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
replicas := int32(1)
|
||||||
|
deploy := &appsv1.Deployment{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: deployName,
|
||||||
|
Namespace: ns,
|
||||||
|
Labels: labels,
|
||||||
|
Annotations: map[string]string{
|
||||||
|
"fission-terraform/mq-trigger-name": name,
|
||||||
|
"fission-terraform/function": functionName,
|
||||||
|
"fission-terraform/queue": queue,
|
||||||
|
"fission-terraform/sqs-endpoint": sqsEndpoint,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Spec: appsv1.DeploymentSpec{
|
||||||
|
Replicas: &replicas,
|
||||||
|
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mq-trigger-name": name}},
|
||||||
|
Template: corev1.PodTemplateSpec{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{
|
||||||
|
"mq-trigger-name": name,
|
||||||
|
mqComponentLabel: mqComponentVal,
|
||||||
|
}},
|
||||||
|
Spec: corev1.PodSpec{
|
||||||
|
Containers: []corev1.Container{{
|
||||||
|
Name: "sqs-consumer",
|
||||||
|
Image: sqsConsumerImage,
|
||||||
|
ImagePullPolicy: corev1.PullAlways,
|
||||||
|
Env: []corev1.EnvVar{
|
||||||
|
{Name: "SQS_QUEUE_NAME", Value: queue},
|
||||||
|
{Name: "SQS_REGION", Value: "us-east-1"},
|
||||||
|
{Name: "FUNCTION_URL", Value: functionURL},
|
||||||
|
{Name: "POLL_INTERVAL", Value: "5"},
|
||||||
|
{Name: "MAX_MESSAGES", Value: "1"},
|
||||||
|
{Name: "MAX_RETRIES", Value: "3"},
|
||||||
|
},
|
||||||
|
EnvFrom: []corev1.EnvFromSource{{
|
||||||
|
SecretRef: &corev1.SecretEnvSource{
|
||||||
|
LocalObjectReference: corev1.LocalObjectReference{Name: secretName},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
Resources: corev1.ResourceRequirements{
|
||||||
|
Limits: corev1.ResourceList{
|
||||||
|
corev1.ResourceCPU: k8sresource.MustParse("50m"),
|
||||||
|
corev1.ResourceMemory: k8sresource.MustParse("32Mi"),
|
||||||
|
},
|
||||||
|
Requests: corev1.ResourceList{
|
||||||
|
corev1.ResourceCPU: k8sresource.MustParse("10m"),
|
||||||
|
corev1.ResourceMemory: k8sresource.MustParse("16Mi"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := c.K8sClient.AppsV1().Deployments(ns).Create(ctx, deploy, metav1.CreateOptions{})
|
||||||
|
if err != nil {
|
||||||
|
_ = c.K8sClient.CoreV1().Secrets(ns).Delete(ctx, secretName, metav1.DeleteOptions{})
|
||||||
|
return nil, fmt.Errorf("create mq deployment %q: %w", deployName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &MQTriggerInfo{
|
||||||
|
Function: functionName,
|
||||||
|
Queue: queue,
|
||||||
|
SQSEndpoint: sqsEndpoint,
|
||||||
|
DeployUID: string(created.UID),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMQTrigger возвращает информацию о MQ trigger по аннотациям Deployment.
|
||||||
|
func (c *Client) GetMQTrigger(ctx context.Context, ns, name string) (*MQTriggerInfo, error) {
|
||||||
|
deploy, err := c.K8sClient.AppsV1().Deployments(ns).Get(ctx, mqDeployName(name), metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get mq deployment %q: %w", name, err)
|
||||||
|
}
|
||||||
|
ann := deploy.Annotations
|
||||||
|
return &MQTriggerInfo{
|
||||||
|
Function: ann["fission-terraform/function"],
|
||||||
|
Queue: ann["fission-terraform/queue"],
|
||||||
|
SQSEndpoint: ann["fission-terraform/sqs-endpoint"],
|
||||||
|
DeployUID: string(deploy.UID),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMQTrigger удаляет Deployment + Secret MQ trigger.
|
||||||
|
func (c *Client) DeleteMQTrigger(ctx context.Context, ns, name string) error {
|
||||||
|
dErr := c.K8sClient.AppsV1().Deployments(ns).Delete(ctx, mqDeployName(name), metav1.DeleteOptions{})
|
||||||
|
sErr := c.K8sClient.CoreV1().Secrets(ns).Delete(ctx, mqSecretName(name), metav1.DeleteOptions{})
|
||||||
|
if dErr != nil && !apierrors.IsNotFound(dErr) {
|
||||||
|
return fmt.Errorf("delete mq deployment %q: %w", name, dErr)
|
||||||
|
}
|
||||||
|
if sErr != nil && !apierrors.IsNotFound(sErr) {
|
||||||
|
return fmt.Errorf("delete mq secret %q: %w", name, sErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TimeTrigger (Fission CRD) ────────────────────────────────────────
|
||||||
|
|
||||||
|
// CreateTimeTrigger создаёт объект TimeTrigger (CRON) в заданном namespace.
|
||||||
|
func (c *Client) CreateTimeTrigger(ctx context.Context, tt *unstructured.Unstructured) (*unstructured.Unstructured, error) {
|
||||||
|
created, err := c.DynClient.Resource(timeTriggerGVR).Namespace(tt.GetNamespace()).Create(ctx, tt, metav1.CreateOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create time trigger %q: %w", tt.GetName(), err)
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTimeTrigger возвращает объект TimeTrigger по имени.
|
||||||
|
func (c *Client) GetTimeTrigger(ctx context.Context, namespace, name string) (*unstructured.Unstructured, error) {
|
||||||
|
tt, err := c.DynClient.Resource(timeTriggerGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get time trigger %q: %w", name, err)
|
||||||
|
}
|
||||||
|
return tt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTimeTrigger обновляет объект TimeTrigger.
|
||||||
|
func (c *Client) UpdateTimeTrigger(ctx context.Context, tt *unstructured.Unstructured) (*unstructured.Unstructured, error) {
|
||||||
|
updated, err := c.DynClient.Resource(timeTriggerGVR).Namespace(tt.GetNamespace()).Update(ctx, tt, metav1.UpdateOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("update time trigger %q: %w", tt.GetName(), err)
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTimeTrigger удаляет объект TimeTrigger по имени.
|
||||||
|
func (c *Client) DeleteTimeTrigger(ctx context.Context, namespace, name string) error {
|
||||||
|
if err := c.DynClient.Resource(timeTriggerGVR).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{}); err != nil {
|
||||||
|
return fmt.Errorf("delete time trigger %q: %w", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IoTDevice (CRD iot.kube5s.ru/v1alpha1) ──────────────────────────
|
||||||
|
|
||||||
|
// IoTDeviceInfo — данные IoTDevice из статуса CRD.
|
||||||
|
type IoTDeviceInfo struct {
|
||||||
|
Phase string
|
||||||
|
MQTTUsername string
|
||||||
|
SecretName string
|
||||||
|
TopicPrefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateIoTDevice создаёт объект IoTDevice.
|
||||||
|
func (c *Client) CreateIoTDevice(ctx context.Context, name, namespace, deviceID string, metadata map[string]string) (*IoTDeviceInfo, error) {
|
||||||
|
obj := &unstructured.Unstructured{
|
||||||
|
Object: map[string]any{
|
||||||
|
"apiVersion": "iot.kube5s.ru/v1alpha1",
|
||||||
|
"kind": "IoTDevice",
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"namespace": namespace,
|
||||||
|
},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"deviceId": deviceID,
|
||||||
|
"enabled": true,
|
||||||
|
"metadata": metadata,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
created, err := c.DynClient.Resource(iotDeviceGVR).Namespace(namespace).Create(ctx, obj, metav1.CreateOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create iot device %q: %w", name, err)
|
||||||
|
}
|
||||||
|
return extractIoTDeviceInfo(created), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIoTDevice возвращает IoTDevice по имени.
|
||||||
|
func (c *Client) GetIoTDevice(ctx context.Context, namespace, name string) (*IoTDeviceInfo, error) {
|
||||||
|
obj, err := c.DynClient.Resource(iotDeviceGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get iot device %q: %w", name, err)
|
||||||
|
}
|
||||||
|
return extractIoTDeviceInfo(obj), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteIoTDevice удаляет IoTDevice по имени.
|
||||||
|
func (c *Client) DeleteIoTDevice(ctx context.Context, namespace, name string) error {
|
||||||
|
if err := c.DynClient.Resource(iotDeviceGVR).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{}); err != nil {
|
||||||
|
return fmt.Errorf("delete iot device %q: %w", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractIoTDeviceInfo(obj *unstructured.Unstructured) *IoTDeviceInfo {
|
||||||
|
status, _, _ := unstructured.NestedMap(obj.Object, "status")
|
||||||
|
phase, _ := status["phase"].(string)
|
||||||
|
mqttUsername, _ := status["mqttUsername"].(string)
|
||||||
|
secretName, _ := status["secretName"].(string)
|
||||||
|
topicPrefix, _ := status["topicPrefix"].(string)
|
||||||
|
return &IoTDeviceInfo{
|
||||||
|
Phase: phase,
|
||||||
|
MQTTUsername: mqttUsername,
|
||||||
|
SecretName: secretName,
|
||||||
|
TopicPrefix: topicPrefix,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -99,6 +99,9 @@ func (p *FissionProvider) Resources(_ context.Context) []func() resource.Resourc
|
|||||||
resources.NewFunctionResource,
|
resources.NewFunctionResource,
|
||||||
resources.NewHTTPTriggerResource,
|
resources.NewHTTPTriggerResource,
|
||||||
resources.NewSimpleFunctionResource,
|
resources.NewSimpleFunctionResource,
|
||||||
|
resources.NewMQTriggerResource,
|
||||||
|
resources.NewCronTriggerResource,
|
||||||
|
resources.NewIoTDeviceResource,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package resources
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||||
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
|
|
||||||
|
"terraform-provider-fission/internal/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ resource.Resource = &CronTriggerResource{}
|
||||||
|
|
||||||
|
// CronTriggerResource управляет Fission TimeTrigger (CRON).
|
||||||
|
type CronTriggerResource struct {
|
||||||
|
client *client.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type cronTriggerResourceModel struct {
|
||||||
|
ID types.String `tfsdk:"id"`
|
||||||
|
Name types.String `tfsdk:"name"`
|
||||||
|
Namespace types.String `tfsdk:"namespace"`
|
||||||
|
Function types.String `tfsdk:"function"`
|
||||||
|
Cron types.String `tfsdk:"cron"`
|
||||||
|
UID types.String `tfsdk:"uid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCronTriggerResource() resource.Resource {
|
||||||
|
return &CronTriggerResource{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CronTriggerResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
||||||
|
resp.TypeName = req.ProviderTypeName + "_cron_trigger"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CronTriggerResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
|
||||||
|
resp.Schema = schema.Schema{
|
||||||
|
Description: "Fission TimeTrigger — запускает функцию по CRON-расписанию.",
|
||||||
|
Attributes: map[string]schema.Attribute{
|
||||||
|
"id": schema.StringAttribute{
|
||||||
|
Computed: true,
|
||||||
|
Description: "Идентификатор ресурса (namespace/name).",
|
||||||
|
},
|
||||||
|
"name": schema.StringAttribute{
|
||||||
|
Required: true,
|
||||||
|
Description: "Имя TimeTrigger.",
|
||||||
|
PlanModifiers: []planmodifier.String{
|
||||||
|
stringplanmodifier.RequiresReplace(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"namespace": schema.StringAttribute{
|
||||||
|
Optional: true,
|
||||||
|
Computed: true,
|
||||||
|
Description: "Kubernetes namespace.",
|
||||||
|
PlanModifiers: []planmodifier.String{
|
||||||
|
stringplanmodifier.RequiresReplace(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"function": schema.StringAttribute{
|
||||||
|
Required: true,
|
||||||
|
Description: "Имя Fission Function для запуска.",
|
||||||
|
},
|
||||||
|
"cron": schema.StringAttribute{
|
||||||
|
Required: true,
|
||||||
|
Description: "CRON-расписание, например \"*/10 * * * *\".",
|
||||||
|
},
|
||||||
|
"uid": schema.StringAttribute{
|
||||||
|
Computed: true,
|
||||||
|
Description: "UID объекта в Kubernetes.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CronTriggerResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
|
||||||
|
if req.ProviderData == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, ok := req.ProviderData.(*client.Client)
|
||||||
|
if !ok {
|
||||||
|
resp.Diagnostics.AddError("Неверный тип провайдера", fmt.Sprintf("ожидался *client.Client, получен %T", req.ProviderData))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.client = c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CronTriggerResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
||||||
|
var plan cronTriggerResourceModel
|
||||||
|
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ns := plan.Namespace.ValueString()
|
||||||
|
if ns == "" {
|
||||||
|
ns = r.client.Namespace
|
||||||
|
}
|
||||||
|
|
||||||
|
obj := buildTimeTrigger(plan.Name.ValueString(), ns, plan.Function.ValueString(), plan.Cron.ValueString())
|
||||||
|
created, err := r.client.CreateTimeTrigger(ctx, obj)
|
||||||
|
if err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка создания CRON trigger", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.ID = types.StringValue(ns + "/" + plan.Name.ValueString())
|
||||||
|
plan.Namespace = types.StringValue(ns)
|
||||||
|
plan.UID = types.StringValue(string(created.GetUID()))
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CronTriggerResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
|
||||||
|
var state cronTriggerResourceModel
|
||||||
|
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
obj, err := r.client.GetTimeTrigger(ctx, state.Namespace.ValueString(), state.Name.ValueString())
|
||||||
|
if err != nil {
|
||||||
|
if client.IsNotFound(err) {
|
||||||
|
resp.State.RemoveResource(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.Diagnostics.AddError("Ошибка чтения CRON trigger", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spec, _, _ := unstructured.NestedMap(obj.Object, "spec")
|
||||||
|
if cron, ok := spec["cron"].(string); ok {
|
||||||
|
state.Cron = types.StringValue(cron)
|
||||||
|
}
|
||||||
|
if fnRef, ok := spec["functionref"].(map[string]any); ok {
|
||||||
|
if name, ok := fnRef["name"].(string); ok {
|
||||||
|
state.Function = types.StringValue(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.UID = types.StringValue(string(obj.GetUID()))
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CronTriggerResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
|
||||||
|
var plan cronTriggerResourceModel
|
||||||
|
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := r.client.GetTimeTrigger(ctx, plan.Namespace.ValueString(), plan.Name.ValueString())
|
||||||
|
if err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка получения CRON trigger для обновления", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := unstructured.SetNestedField(existing.Object, plan.Cron.ValueString(), "spec", "cron"); err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка обновления cron", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := unstructured.SetNestedField(existing.Object, plan.Function.ValueString(), "spec", "functionref", "name"); err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка обновления function", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := r.client.UpdateTimeTrigger(ctx, existing)
|
||||||
|
if err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка обновления CRON trigger", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.UID = types.StringValue(string(updated.GetUID()))
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CronTriggerResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
|
||||||
|
var state cronTriggerResourceModel
|
||||||
|
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.client.DeleteTimeTrigger(ctx, state.Namespace.ValueString(), state.Name.ValueString()); err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка удаления CRON trigger", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTimeTrigger(name, ns, functionName, cron string) *unstructured.Unstructured {
|
||||||
|
return &unstructured.Unstructured{
|
||||||
|
Object: map[string]any{
|
||||||
|
"apiVersion": "fission.io/v1",
|
||||||
|
"kind": "TimeTrigger",
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"namespace": ns,
|
||||||
|
},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"cron": cron,
|
||||||
|
"functionref": map[string]any{
|
||||||
|
"type": "name",
|
||||||
|
"name": functionName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
package resources
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/attr"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/mapdefault"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||||
|
|
||||||
|
"terraform-provider-fission/internal/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ resource.Resource = &IoTDeviceResource{}
|
||||||
|
|
||||||
|
// IoTDeviceResource управляет CRD IoTDevice (iot.kube5s.ru/v1alpha1).
|
||||||
|
type IoTDeviceResource struct {
|
||||||
|
client *client.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type iotDeviceResourceModel struct {
|
||||||
|
ID types.String `tfsdk:"id"`
|
||||||
|
Name types.String `tfsdk:"name"`
|
||||||
|
Namespace types.String `tfsdk:"namespace"`
|
||||||
|
DeviceID types.String `tfsdk:"device_id"`
|
||||||
|
Enabled types.Bool `tfsdk:"enabled"`
|
||||||
|
Metadata types.Map `tfsdk:"metadata"`
|
||||||
|
Phase types.String `tfsdk:"phase"`
|
||||||
|
MQTTUsername types.String `tfsdk:"mqtt_username"`
|
||||||
|
SecretName types.String `tfsdk:"secret_name"`
|
||||||
|
TopicPrefix types.String `tfsdk:"topic_prefix"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIoTDeviceResource() resource.Resource {
|
||||||
|
return &IoTDeviceResource{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *IoTDeviceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
||||||
|
resp.TypeName = req.ProviderTypeName + "_iot_device"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *IoTDeviceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
|
||||||
|
resp.Schema = schema.Schema{
|
||||||
|
Description: "IoT-устройство, зарегистрированное в платформе sless (CRD iot.kube5s.ru/v1alpha1/IoTDevice).",
|
||||||
|
Attributes: map[string]schema.Attribute{
|
||||||
|
"id": schema.StringAttribute{
|
||||||
|
Computed: true,
|
||||||
|
Description: "Идентификатор ресурса (namespace/name).",
|
||||||
|
},
|
||||||
|
"name": schema.StringAttribute{
|
||||||
|
Required: true,
|
||||||
|
Description: "Имя IoTDevice.",
|
||||||
|
PlanModifiers: []planmodifier.String{
|
||||||
|
stringplanmodifier.RequiresReplace(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"namespace": schema.StringAttribute{
|
||||||
|
Optional: true,
|
||||||
|
Computed: true,
|
||||||
|
Default: stringdefault.StaticString("sless"),
|
||||||
|
Description: "Namespace IoTDevice. По умолчанию sless.",
|
||||||
|
PlanModifiers: []planmodifier.String{
|
||||||
|
stringplanmodifier.RequiresReplace(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"device_id": schema.StringAttribute{
|
||||||
|
Required: true,
|
||||||
|
Description: "Уникальный ID устройства внутри namespace (строчные буквы, цифры, дефис).",
|
||||||
|
PlanModifiers: []planmodifier.String{
|
||||||
|
stringplanmodifier.RequiresReplace(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"enabled": schema.BoolAttribute{
|
||||||
|
Optional: true,
|
||||||
|
Computed: true,
|
||||||
|
Default: booldefault.StaticBool(true),
|
||||||
|
Description: "Активно ли устройство.",
|
||||||
|
},
|
||||||
|
"metadata": schema.MapAttribute{
|
||||||
|
Optional: true,
|
||||||
|
Computed: true,
|
||||||
|
ElementType: types.StringType,
|
||||||
|
Default: mapdefault.StaticValue(types.MapValueMust(types.StringType, map[string]attr.Value{})),
|
||||||
|
Description: "Произвольные метаданные устройства (модель, локация и т.д.).",
|
||||||
|
},
|
||||||
|
// Computed (заполняет iot-operator)
|
||||||
|
"phase": schema.StringAttribute{
|
||||||
|
Computed: true,
|
||||||
|
Description: "Текущая фаза: Active, Disabled, Pending, Error.",
|
||||||
|
},
|
||||||
|
"mqtt_username": schema.StringAttribute{
|
||||||
|
Computed: true,
|
||||||
|
Description: "MQTT username, выданный iot-operator.",
|
||||||
|
},
|
||||||
|
"secret_name": schema.StringAttribute{
|
||||||
|
Computed: true,
|
||||||
|
Description: "Имя K8s Secret с MQTT credentials.",
|
||||||
|
},
|
||||||
|
"topic_prefix": schema.StringAttribute{
|
||||||
|
Computed: true,
|
||||||
|
Description: "MQTT topic prefix для публикации.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *IoTDeviceResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
|
||||||
|
if req.ProviderData == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, ok := req.ProviderData.(*client.Client)
|
||||||
|
if !ok {
|
||||||
|
resp.Diagnostics.AddError("Неверный тип провайдера", fmt.Sprintf("ожидался *client.Client, получен %T", req.ProviderData))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.client = c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *IoTDeviceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
||||||
|
var plan iotDeviceResourceModel
|
||||||
|
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ns := plan.Namespace.ValueString()
|
||||||
|
meta := map[string]string{}
|
||||||
|
if !plan.Metadata.IsNull() && !plan.Metadata.IsUnknown() {
|
||||||
|
elems := plan.Metadata.Elements()
|
||||||
|
for k, v := range elems {
|
||||||
|
if sv, ok := v.(types.String); ok {
|
||||||
|
meta[k] = sv.ValueString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := r.client.CreateIoTDevice(ctx, plan.Name.ValueString(), ns, plan.DeviceID.ValueString(), meta)
|
||||||
|
if err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка создания IoT device", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.ID = types.StringValue(ns + "/" + plan.Name.ValueString())
|
||||||
|
plan.Phase = types.StringValue(info.Phase)
|
||||||
|
plan.MQTTUsername = types.StringValue(info.MQTTUsername)
|
||||||
|
plan.SecretName = types.StringValue(info.SecretName)
|
||||||
|
plan.TopicPrefix = types.StringValue(info.TopicPrefix)
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *IoTDeviceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
|
||||||
|
var state iotDeviceResourceModel
|
||||||
|
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := r.client.GetIoTDevice(ctx, state.Namespace.ValueString(), state.Name.ValueString())
|
||||||
|
if err != nil {
|
||||||
|
if client.IsNotFound(err) {
|
||||||
|
resp.State.RemoveResource(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.Diagnostics.AddError("Ошибка чтения IoT device", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state.Phase = types.StringValue(info.Phase)
|
||||||
|
state.MQTTUsername = types.StringValue(info.MQTTUsername)
|
||||||
|
state.SecretName = types.StringValue(info.SecretName)
|
||||||
|
state.TopicPrefix = types.StringValue(info.TopicPrefix)
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *IoTDeviceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
|
||||||
|
// name/namespace/device_id — RequiresReplace, значит Update только для enabled/metadata.
|
||||||
|
// Для простоты: пересоздаём объект.
|
||||||
|
var plan iotDeviceResourceModel
|
||||||
|
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ns := plan.Namespace.ValueString()
|
||||||
|
name := plan.Name.ValueString()
|
||||||
|
|
||||||
|
if err := r.client.DeleteIoTDevice(ctx, ns, name); err != nil && !client.IsNotFound(err) {
|
||||||
|
resp.Diagnostics.AddError("Ошибка удаления IoT device при обновлении", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := map[string]string{}
|
||||||
|
if !plan.Metadata.IsNull() && !plan.Metadata.IsUnknown() {
|
||||||
|
elems := plan.Metadata.Elements()
|
||||||
|
for k, v := range elems {
|
||||||
|
if sv, ok := v.(types.String); ok {
|
||||||
|
meta[k] = sv.ValueString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := r.client.CreateIoTDevice(ctx, name, ns, plan.DeviceID.ValueString(), meta)
|
||||||
|
if err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка создания IoT device при обновлении", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.Phase = types.StringValue(info.Phase)
|
||||||
|
plan.MQTTUsername = types.StringValue(info.MQTTUsername)
|
||||||
|
plan.SecretName = types.StringValue(info.SecretName)
|
||||||
|
plan.TopicPrefix = types.StringValue(info.TopicPrefix)
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *IoTDeviceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
|
||||||
|
var state iotDeviceResourceModel
|
||||||
|
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.client.DeleteIoTDevice(ctx, state.Namespace.ValueString(), state.Name.ValueString()); err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка удаления IoT device", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package resources
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||||
|
|
||||||
|
"terraform-provider-fission/internal/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ resource.Resource = &MQTriggerResource{}
|
||||||
|
|
||||||
|
// MQTriggerResource управляет MQ-триггером через K8s Deployment+Secret (sqs-consumer).
|
||||||
|
type MQTriggerResource struct {
|
||||||
|
client *client.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type mqTriggerResourceModel struct {
|
||||||
|
ID types.String `tfsdk:"id"`
|
||||||
|
Name types.String `tfsdk:"name"`
|
||||||
|
Namespace types.String `tfsdk:"namespace"`
|
||||||
|
Function types.String `tfsdk:"function"`
|
||||||
|
Queue types.String `tfsdk:"queue"`
|
||||||
|
AccessKey types.String `tfsdk:"access_key"`
|
||||||
|
SecretKey types.String `tfsdk:"secret_key"`
|
||||||
|
SQSEndpoint types.String `tfsdk:"sqs_endpoint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMQTriggerResource() resource.Resource {
|
||||||
|
return &MQTriggerResource{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MQTriggerResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
||||||
|
resp.TypeName = req.ProviderTypeName + "_mq_trigger"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MQTriggerResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
|
||||||
|
resp.Schema = schema.Schema{
|
||||||
|
Description: "MQ-триггер через K8s Deployment (sqs-consumer) + Secret с SQS credentials.",
|
||||||
|
Attributes: map[string]schema.Attribute{
|
||||||
|
"id": schema.StringAttribute{
|
||||||
|
Computed: true,
|
||||||
|
Description: "Идентификатор ресурса (namespace/name).",
|
||||||
|
},
|
||||||
|
"name": schema.StringAttribute{
|
||||||
|
Required: true,
|
||||||
|
Description: "Имя MQ trigger.",
|
||||||
|
PlanModifiers: []planmodifier.String{
|
||||||
|
stringplanmodifier.RequiresReplace(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"namespace": schema.StringAttribute{
|
||||||
|
Optional: true,
|
||||||
|
Computed: true,
|
||||||
|
Description: "Kubernetes namespace.",
|
||||||
|
PlanModifiers: []planmodifier.String{
|
||||||
|
stringplanmodifier.RequiresReplace(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"function": schema.StringAttribute{
|
||||||
|
Required: true,
|
||||||
|
Description: "Имя Fission Function, к которой направляются сообщения из очереди.",
|
||||||
|
},
|
||||||
|
"queue": schema.StringAttribute{
|
||||||
|
Required: true,
|
||||||
|
Description: "Имя SQS очереди.",
|
||||||
|
},
|
||||||
|
"access_key": schema.StringAttribute{
|
||||||
|
Required: true,
|
||||||
|
Sensitive: true,
|
||||||
|
Description: "SQS Access Key.",
|
||||||
|
},
|
||||||
|
"secret_key": schema.StringAttribute{
|
||||||
|
Required: true,
|
||||||
|
Sensitive: true,
|
||||||
|
Description: "SQS Secret Key.",
|
||||||
|
},
|
||||||
|
"sqs_endpoint": schema.StringAttribute{
|
||||||
|
Optional: true,
|
||||||
|
Computed: true,
|
||||||
|
Default: stringdefault.StaticString("http://shared-sqs.shared-sqs.svc.cluster.local:4100"),
|
||||||
|
Description: "SQS endpoint URL. По умолчанию — внутренний shared-sqs.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MQTriggerResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
|
||||||
|
if req.ProviderData == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, ok := req.ProviderData.(*client.Client)
|
||||||
|
if !ok {
|
||||||
|
resp.Diagnostics.AddError("Неверный тип провайдера", fmt.Sprintf("ожидался *client.Client, получен %T", req.ProviderData))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.client = c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MQTriggerResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
||||||
|
var plan mqTriggerResourceModel
|
||||||
|
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ns := plan.Namespace.ValueString()
|
||||||
|
if ns == "" {
|
||||||
|
ns = r.client.Namespace
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := r.client.CreateMQTrigger(
|
||||||
|
ctx,
|
||||||
|
plan.Name.ValueString(),
|
||||||
|
ns,
|
||||||
|
plan.Function.ValueString(),
|
||||||
|
plan.Queue.ValueString(),
|
||||||
|
plan.SQSEndpoint.ValueString(),
|
||||||
|
plan.AccessKey.ValueString(),
|
||||||
|
plan.SecretKey.ValueString(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка создания MQ trigger", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.ID = types.StringValue(ns + "/" + plan.Name.ValueString())
|
||||||
|
plan.Namespace = types.StringValue(ns)
|
||||||
|
plan.SQSEndpoint = types.StringValue(info.SQSEndpoint)
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MQTriggerResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
|
||||||
|
var state mqTriggerResourceModel
|
||||||
|
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ns := state.Namespace.ValueString()
|
||||||
|
info, err := r.client.GetMQTrigger(ctx, ns, state.Name.ValueString())
|
||||||
|
if err != nil {
|
||||||
|
if client.IsNotFound(err) {
|
||||||
|
resp.State.RemoveResource(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.Diagnostics.AddError("Ошибка чтения MQ trigger", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state.Function = types.StringValue(info.Function)
|
||||||
|
state.Queue = types.StringValue(info.Queue)
|
||||||
|
state.SQSEndpoint = types.StringValue(info.SQSEndpoint)
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MQTriggerResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
|
||||||
|
var plan mqTriggerResourceModel
|
||||||
|
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ns := plan.Namespace.ValueString()
|
||||||
|
name := plan.Name.ValueString()
|
||||||
|
|
||||||
|
// Удаляем старый и создаём заново с новыми параметрами.
|
||||||
|
if err := r.client.DeleteMQTrigger(ctx, ns, name); err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка удаления MQ trigger при обновлении", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, err := r.client.CreateMQTrigger(
|
||||||
|
ctx, name, ns,
|
||||||
|
plan.Function.ValueString(),
|
||||||
|
plan.Queue.ValueString(),
|
||||||
|
plan.SQSEndpoint.ValueString(),
|
||||||
|
plan.AccessKey.ValueString(),
|
||||||
|
plan.SecretKey.ValueString(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка создания MQ trigger при обновлении", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.SQSEndpoint = types.StringValue(info.SQSEndpoint)
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MQTriggerResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
|
||||||
|
var state mqTriggerResourceModel
|
||||||
|
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.client.DeleteMQTrigger(ctx, state.Namespace.ValueString(), state.Name.ValueString()); err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка удаления MQ trigger", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
ntazetdinov@nubes.ru
|
|
||||||
stxVxLvM9eqssgZ
|
|
||||||
|
|
||||||
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLWFwaSIsInN1YiI6IjAxOWQ3NmM1LTQyNGMtN2MyYy04OWI3LWUzZjA5OTQ5YTk4ZiIsImV4cCI6MTc5MzA3ODQzOSwiaWF0IjoxNzc3NTI2NDM5LCJqdGkiOiI4MTU1ODNkNy0xZTRkLTRiMmMtOGM5ZS0wZDZmMGU2NmJjNTciLCJhdXRoX3RpbWUiOjAsInR5cCI6IiIsImF6cCI6IiIsInNlc3Npb25fc3RhdGUiOiIiLCJhY3IiOiIiLCJhbGxvd2VkLW9yaWdpbnMiOm51bGwsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6bnVsbH0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpudWxsfX0sInNjb3BlIjoiIiwic2lkIjoiIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuYW1lIjoiIiwiQ2xpZW50SUQiOiJXWjAxMTEyIiwiY29tcGFueV9pZCI6IjhlYzcwYWMwLTU0NmQtNDJhNy04Y2ZmLTMzOWM4ZmI1MWEyMyIsImNvbXBhbnlfbmFtZSI6ItCe0J7QniDCq9Cd0KPQkdCV0KHCuyIsImlkcF91c3JfdWlkIjoiMDE5ZDc2YzUtNDI0Yy03YzJjLTg5YjctZTNmMDk5NDlhOThmIiwibG9naW4iOiJudGF6ZXRkaW5vdkBudWJlcy5ydSIsImZpcnN0bmFtZSI6ItCd0LDQuNC70YwiLCJtaWRkbGVuYW1lIjoi0KTQsNGA0LjRgtC-0LLQuNGHIiwibGFzdG5hbWUiOiLQotCw0LfQtdGC0LTQuNC90L7QsiIsImdyb3VwcyI6bnVsbCwicHJlZmVycmVkX3VzZXJuYW1lIjoiIiwiZ2l2ZW5fbmFtZSI6IiIsImZhbWlseV9uYW1lIjoiIiwiZW1haWwiOiJudGF6ZXRkaW5vdkBudWJlcy5ydSJ9.SaZsKAha45-fvKuJkLHUe_09AFsbH5QpzBVdPnjiEDQGIhl1A3ThgnM-oEh_H6CBXXfnOs2QDczzgCD8K8_tYzZKU1Wgk5lj04YW_fbTI89kHTO5wLtrAht9tFEjzBQZ-kmnG8mUK5tgqyNEjsngdQcfqVWRvneF366TiiRXk_76poUPpXWQmqdgCCKb3wq1rRgWcKDZcUC3JvDpOaqYL40zITZlM855drlJhMut4Gkg-EEk7ZGykl6YCsTRWVjFKGQ-7C-BDU2dkmIWhxzBAkvzzM2kBwvtlczCEDiUin7bXE-F_io89oJUYDzY4yPeHzwREwxCp6x9eD1NAIKKuA
|
|
||||||
|
|
||||||
----------- token on VM --------------
|
|
||||||
https://deck-api-test.ngcloud.ru/api/v1 - для тест стенда где постгрес, файл с токеном -
|
|
||||||
naeel@naeel-vm:~/terra/sless$ ll test.token
|
|
||||||
-rw-r--r-- 1 naeel naeel 998 Feb 28 07:18 test.token
|
|
||||||
|
|
||||||
--------------------- pg IoT-PG -----------------------
|
|
||||||
users
|
|
||||||
JSON объект (раскройте для просмотра)
|
|
||||||
|
|
||||||
password:
|
|
||||||
BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif
|
|
||||||
|
|
||||||
username:
|
|
||||||
super
|
|
||||||
|
|
||||||
Поля состояния (сгенерированные значения)
|
|
||||||
monitoring
|
|
||||||
{
|
|
||||||
"resourceMetrics": "https://grafana.ngcloud.ru/d/vzjb4zd/kubernetes-pods?orgId=954&var-namespace=dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5&var-resource_realm=iot-naeel"
|
|
||||||
}
|
|
||||||
|
|
||||||
users
|
|
||||||
{
|
|
||||||
"super": {
|
|
||||||
"role": "ddl_user",
|
|
||||||
"username": "super"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
backups
|
|
||||||
{}
|
|
||||||
|
|
||||||
databases
|
|
||||||
{
|
|
||||||
"sqsdb": {
|
|
||||||
"dbName": "sqsdb",
|
|
||||||
"dbOwner": "super"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
externalConnect
|
|
||||||
{
|
|
||||||
"slave": {
|
|
||||||
"ip": "",
|
|
||||||
"fqdn": "",
|
|
||||||
"uuid": "",
|
|
||||||
"isExternal": false
|
|
||||||
},
|
|
||||||
"master": {
|
|
||||||
"ip": "",
|
|
||||||
"fqdn": "",
|
|
||||||
"uuid": "",
|
|
||||||
"isExternal": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internalConnect
|
|
||||||
{
|
|
||||||
"slave": "",
|
|
||||||
"master": "postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local"
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user