Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d50f0557e9 | ||
|
|
c22d65ea85 |
@@ -28,16 +28,16 @@
|
|||||||
1. Не трогать рабочий код без явного указания.
|
1. Не трогать рабочий код без явного указания.
|
||||||
|
|
||||||
2. Файлы редактируются локально:
|
2. Файлы редактируются локально:
|
||||||
~/fission-console
|
~/fission
|
||||||
|
|
||||||
После ЛЮБЫХ изменений ОБЯЗАТЕЛЬНО синхронизировать на ВМ командой:
|
После ЛЮБЫХ изменений ОБЯЗАТЕЛЬНО синхронизировать на ВМ командой:
|
||||||
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-console/ \
|
~/fission/ \
|
||||||
naeel@5.172.178.213:~/terra/fission/
|
naeel@5.172.178.213:~/terra/fission/
|
||||||
|
|
||||||
|
|
||||||
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission-console
|
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission
|
||||||
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-console`
|
1. Все файлы редактируются локально: `~/fission`
|
||||||
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-console/ \
|
~/fission/ \
|
||||||
naeel@5.172.178.213:~/terra/fission/
|
naeel@5.172.178.213:~/terra/fission/
|
||||||
|
|
||||||
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission-console
|
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission
|
||||||
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-console`. НИКОГДА через SSH на VM.
|
- Git — ТОЛЬКО ЛОКАЛЬНО в `~/fission`. НИКОГДА через 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-console/test-results/
|
naeel@5.172.178.213:~/terra/fission/test-results/ ~/fission/test-results/
|
||||||
```
|
```
|
||||||
|
|
||||||
**Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.**
|
**Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.**
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
.terraform/
|
.terraform/
|
||||||
*.tfstate
|
*.tfstate
|
||||||
*.tfstate.*
|
*.tfstate.*
|
||||||
*.tfvars
|
|
||||||
|
|
||||||
# Go
|
# Go
|
||||||
bin/
|
bin/
|
||||||
@@ -16,14 +15,6 @@ 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
|
|
||||||
/FIX_401_INVOKE.md
|
/FIX_401_INVOKE.md
|
||||||
/deploy/grafana/configmap-datasources.yaml
|
/deploy/grafana/configmap-datasources.yaml
|
||||||
/doc/structure/login-flow.md
|
/doc/structure/login-flow.md
|
||||||
|
|||||||
@@ -235,59 +235,3 @@
|
|||||||
### Вывод
|
### Вывод
|
||||||
- Текущий 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,31 +54,9 @@
|
|||||||
- Не ломай контракт функции выдуманными обертками или неподходящими типами.
|
- Не ломай контракт функции выдуманными обертками или неподходящими типами.
|
||||||
|
|
||||||
### PHP
|
### PHP
|
||||||
- **КРИТИЧНО:** PHP env НЕ читает return value функции. `return ['statusCode'=>200,'body'=>...]` — тело ответа будет ПУСТЫМ.
|
- Возвращай корректный PHP-код функции.
|
||||||
- Запись в ответ — ТОЛЬКО через `$ctx["response"]->getBody()->write(...)`:
|
- Следи за открывающим тегом и синтаксисом массива/строк.
|
||||||
```php
|
- Не выдумывай несуществующие `Fission\\Contract` классы или SDK, если задача про обычную функцию.
|
||||||
<?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,58 +102,6 @@ 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,164 +0,0 @@
|
|||||||
# Handoff: admin-console — задание для DeepSeek
|
|
||||||
|
|
||||||
## Контекст
|
|
||||||
|
|
||||||
Репозиторий `fission-console`, ветка `restructure/repo-layout`.
|
|
||||||
|
|
||||||
Это **Fission Serverless Platform** — кастомная установка. Клиентская консоль (`client-console/`) уже готова и работает в проде. Нужно реализовать **admin-console** (`admin-console/`) — панель для оператора платформы (просмотр всех клиентов, использование ресурсов, данные для бухгалтерии).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что уже сделано (скелет)
|
|
||||||
|
|
||||||
```
|
|
||||||
admin-console/
|
|
||||||
cmd/server/main.go — точка входа, порт 8091, требует ADMIN_TOKEN
|
|
||||||
internal/api/server.go — роуты: /admin/api/namespaces, /usage, /users
|
|
||||||
internal/metrics/k8s.go — k8s-клиент (in-cluster), заглушки для реальных данных
|
|
||||||
internal/model/types.go — типы NamespaceInfo, UsageEntry, UserInfo
|
|
||||||
deploy/admin.yaml — K8s Deployment + ClusterRole (read-only всё)
|
|
||||||
ui/index.html — базовый UI (тёмная тема, 3 вкладки)
|
|
||||||
go.mod — module admin-console, зависит от k8s.io/client-go v0.31.0
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что нужно реализовать
|
|
||||||
|
|
||||||
### 1. `internal/metrics/k8s.go` — подсчёт Fission CRD
|
|
||||||
|
|
||||||
Метод `ListClientNamespaces` должен для каждого namespace считать объекты через Dynamic client:
|
|
||||||
|
|
||||||
```go
|
|
||||||
import (
|
|
||||||
"k8s.io/client-go/dynamic"
|
|
||||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
||||||
)
|
|
||||||
|
|
||||||
dynamicClient, _ := dynamic.NewForConfig(cfg)
|
|
||||||
|
|
||||||
gvr := schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "functions"}
|
|
||||||
list, err := dynamicClient.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{})
|
|
||||||
info.Functions = len(list.Items)
|
|
||||||
```
|
|
||||||
|
|
||||||
Аналогично для: `packages`, `environments`, `httptriggers`, `timetriggers`.
|
|
||||||
|
|
||||||
Сохрани `dynamicClient` в структуру `K8sClient` и используй его в обоих методах.
|
|
||||||
|
|
||||||
### 2. `internal/metrics/k8s.go` — S3-хранилище в `CollectUsage`
|
|
||||||
|
|
||||||
Поле `StorageGB` — читать из S3. Конфигурация через env:
|
|
||||||
- `S3_ENDPOINT` = `https://s3.msk-1.ngcloud.ru`
|
|
||||||
- `S3_BUCKET` = `sless-functions`
|
|
||||||
- `S3_ACCESS_KEY`, `S3_SECRET_KEY`
|
|
||||||
|
|
||||||
Использовать `github.com/aws/aws-sdk-go-v2`. Для каждого namespace prefix = `{namespace}/`, суммировать размер всех объектов.
|
|
||||||
|
|
||||||
```go
|
|
||||||
import (
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
|
||||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
|
||||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
|
||||||
awscfg "github.com/aws/aws-sdk-go-v2/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
cfg, _ := awscfg.LoadDefaultConfig(ctx,
|
|
||||||
awscfg.WithRegion("ru-msk-1"),
|
|
||||||
awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
|
|
||||||
awscfg.WithEndpointResolverWithOptions(...), // custom endpoint
|
|
||||||
)
|
|
||||||
client := s3.NewFromConfig(cfg, func(o *s3.Options) { o.UsePathStyle = true })
|
|
||||||
|
|
||||||
paginator := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{
|
|
||||||
Bucket: aws.String(bucket),
|
|
||||||
Prefix: aws.String(namespace + "/"),
|
|
||||||
})
|
|
||||||
var totalBytes int64
|
|
||||||
for paginator.HasMorePages() {
|
|
||||||
page, _ := paginator.NextPage(ctx)
|
|
||||||
for _, obj := range page.Contents {
|
|
||||||
totalBytes += aws.ToInt64(obj.Size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
entry.StorageGB = float64(totalBytes) / (1024 * 1024 * 1024)
|
|
||||||
```
|
|
||||||
|
|
||||||
Поля `Invocations`, `CPUMilliCores`, `MemoryMB` — оставить 0 (TODO).
|
|
||||||
|
|
||||||
### 3. `go.mod` — добавить зависимости
|
|
||||||
|
|
||||||
```
|
|
||||||
github.com/aws/aws-sdk-go-v2 v1.30.0
|
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.27.0
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.0
|
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.0
|
|
||||||
```
|
|
||||||
|
|
||||||
Запустить `go mod tidy` после добавления.
|
|
||||||
|
|
||||||
### 4. `deploy/admin.yaml` — добавить env в контейнер
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
env:
|
|
||||||
- name: ADMIN_TOKEN
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: fission-admin-token
|
|
||||||
key: token
|
|
||||||
- name: S3_ENDPOINT
|
|
||||||
value: "https://s3.msk-1.ngcloud.ru"
|
|
||||||
- name: S3_BUCKET
|
|
||||||
value: "sless-functions"
|
|
||||||
- name: S3_ACCESS_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: fission-s3-credentials
|
|
||||||
key: access_key
|
|
||||||
- name: S3_SECRET_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: fission-s3-credentials
|
|
||||||
key: secret_key
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. `ui/index.html` — доработать UI
|
|
||||||
|
|
||||||
- В таблице "Использование" показывать реальное поле `storage_gb` (уже есть в шаблоне).
|
|
||||||
- Добавить кнопку **"Экспорт CSV"** над таблицей Usage — генерировать CSV из текущих данных и скачивать через `Blob`:
|
|
||||||
|
|
||||||
```js
|
|
||||||
function exportCSV(rows) {
|
|
||||||
const header = 'Namespace,Email,Функции,Вызовы,Хранилище GB,Период\n';
|
|
||||||
const body = rows.map(u =>
|
|
||||||
[u.namespace, u.user_email, u.functions, u.invocations_total,
|
|
||||||
u.storage_gb.toFixed(3), `${u.period_start} — ${u.period_end}`].join(',')
|
|
||||||
).join('\n');
|
|
||||||
const blob = new Blob([header + body], { type: 'text/csv;charset=utf-8;' });
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = URL.createObjectURL(blob);
|
|
||||||
a.download = `usage-${new Date().toISOString().slice(0,10)}.csv`;
|
|
||||||
a.click();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Правила среды
|
|
||||||
|
|
||||||
- **Go version**: `1.25.0` (не менять в go.mod)
|
|
||||||
- **Образ**: `naeel/fission-admin-console:v0.1.0`
|
|
||||||
- **Build + push**: только через SSH на ВМ `naeel@5.172.178.213` (ключ у оператора)
|
|
||||||
- **Namespace деплоя**: `fission`
|
|
||||||
- **S3-секрет** `fission-s3-credentials` уже существует в namespace `fission`
|
|
||||||
- **Admin-токен** создать вручную перед деплоем:
|
|
||||||
```bash
|
|
||||||
kubectl create secret generic fission-admin-token -n fission --from-literal=token=<случайная_строка>
|
|
||||||
```
|
|
||||||
- Деплой: `kubectl apply -f ~/terra/fission/admin-console/deploy/admin.yaml`
|
|
||||||
|
|
||||||
## Что НЕ трогать
|
|
||||||
|
|
||||||
- `client-console/` — рабочий код в проде, **не трогать вообще**
|
|
||||||
- `helm/`, `terraform/`, `scripts/` — не трогать
|
|
||||||
- Любой файл вне `admin-console/` — без явного разрешения не трогать
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
FROM golang:1.26-alpine AS builder
|
|
||||||
WORKDIR /build
|
|
||||||
COPY . .
|
|
||||||
RUN go mod tidy
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o admin-console ./cmd/server/
|
|
||||||
|
|
||||||
FROM alpine:3.20
|
|
||||||
RUN apk add --no-cache ca-certificates
|
|
||||||
COPY --from=builder /build/admin-console /admin-console
|
|
||||||
COPY ui/ /ui/
|
|
||||||
EXPOSE 8091
|
|
||||||
ENTRYPOINT ["/admin-console"]
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"admin-console/internal/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
adminToken := os.Getenv("ADMIN_TOKEN")
|
|
||||||
if adminToken == "" {
|
|
||||||
log.Fatal("ADMIN_TOKEN env variable is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
srv := api.NewServer(adminToken)
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
srv.RegisterRoutes(mux)
|
|
||||||
|
|
||||||
addr := ":8091"
|
|
||||||
log.Printf("admin-console starting on %s", addr)
|
|
||||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
|
||||||
log.Fatalf("server error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: ServiceAccount
|
|
||||||
metadata:
|
|
||||||
name: fission-admin-console
|
|
||||||
namespace: fission
|
|
||||||
---
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: ClusterRole
|
|
||||||
metadata:
|
|
||||||
name: fission-admin-console
|
|
||||||
rules:
|
|
||||||
# читать ВСЕ namespaces (для отображения клиентов)
|
|
||||||
- apiGroups: [""]
|
|
||||||
resources: ["namespaces"]
|
|
||||||
verbs: ["get", "list"]
|
|
||||||
# читать секреты в клиентских namespaces (для списка пользователей)
|
|
||||||
- apiGroups: [""]
|
|
||||||
resources: ["secrets"]
|
|
||||||
verbs: ["get", "list"]
|
|
||||||
# читать pods (для метрик потребления)
|
|
||||||
- apiGroups: [""]
|
|
||||||
resources: ["pods"]
|
|
||||||
verbs: ["get", "list"]
|
|
||||||
# читать Fission CRD
|
|
||||||
- apiGroups: ["fission.io"]
|
|
||||||
resources: ["environments", "packages", "functions", "httptriggers", "timetriggers"]
|
|
||||||
verbs: ["get", "list"]
|
|
||||||
---
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: ClusterRoleBinding
|
|
||||||
metadata:
|
|
||||||
name: fission-admin-console
|
|
||||||
roleRef:
|
|
||||||
apiGroup: rbac.authorization.k8s.io
|
|
||||||
kind: ClusterRole
|
|
||||||
name: fission-admin-console
|
|
||||||
subjects:
|
|
||||||
- kind: ServiceAccount
|
|
||||||
name: fission-admin-console
|
|
||||||
namespace: fission
|
|
||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: fission-admin-console
|
|
||||||
namespace: fission
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: fission-admin-console
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: fission-admin-console
|
|
||||||
spec:
|
|
||||||
serviceAccountName: fission-admin-console
|
|
||||||
containers:
|
|
||||||
- name: fission-admin-console
|
|
||||||
image: naeel/fission-admin-console:v0.1.1
|
|
||||||
imagePullPolicy: Always
|
|
||||||
ports:
|
|
||||||
- containerPort: 8091
|
|
||||||
env:
|
|
||||||
- name: ADMIN_TOKEN
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: fission-admin-token
|
|
||||||
key: token
|
|
||||||
- name: S3_ENDPOINT
|
|
||||||
value: "https://s3.msk-1.ngcloud.ru"
|
|
||||||
- name: S3_BUCKET
|
|
||||||
value: "sless-functions"
|
|
||||||
- name: S3_ACCESS_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: fission-s3-credentials
|
|
||||||
key: access_key
|
|
||||||
- name: S3_SECRET_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: fission-s3-credentials
|
|
||||||
key: secret_key
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: fission-admin-console
|
|
||||||
namespace: fission
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: fission-admin-console
|
|
||||||
ports:
|
|
||||||
- port: 8091
|
|
||||||
targetPort: 8091
|
|
||||||
---
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: fission-admin-console
|
|
||||||
namespace: fission
|
|
||||||
annotations:
|
|
||||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
|
||||||
spec:
|
|
||||||
ingressClassName: nginx
|
|
||||||
rules:
|
|
||||||
- host: fission.kube5s.ru
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- backend:
|
|
||||||
service:
|
|
||||||
name: fission-admin-console
|
|
||||||
port:
|
|
||||||
number: 8091
|
|
||||||
path: /admin
|
|
||||||
pathType: Prefix
|
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- fission.kube5s.ru
|
|
||||||
secretName: fission-tls
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
module admin-console
|
|
||||||
|
|
||||||
go 1.25.0
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/aws/aws-sdk-go-v2 v1.30.0
|
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.27.0
|
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.0
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.0
|
|
||||||
k8s.io/api v0.31.0
|
|
||||||
k8s.io/apimachinery v0.31.0
|
|
||||||
k8s.io/client-go v0.31.0
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"admin-console/internal/metrics"
|
|
||||||
"admin-console/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Server — HTTP-сервер admin-console.
|
|
||||||
type Server struct {
|
|
||||||
adminToken string
|
|
||||||
k8s *metrics.K8sClient
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewServer(adminToken string) *Server {
|
|
||||||
k8s, err := metrics.NewK8sClient()
|
|
||||||
if err != nil {
|
|
||||||
// вне кластера — работаем без k8s (для локальной разработки)
|
|
||||||
k8s = nil
|
|
||||||
}
|
|
||||||
return &Server{adminToken: adminToken, k8s: k8s}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
|
||||||
// статика UI
|
|
||||||
mux.Handle("/admin/", http.StripPrefix("/admin/", http.FileServer(http.Dir("ui"))))
|
|
||||||
|
|
||||||
// API
|
|
||||||
mux.HandleFunc("/admin/api/health", s.handleHealth)
|
|
||||||
mux.HandleFunc("/admin/api/namespaces", s.authMiddleware(s.handleNamespaces))
|
|
||||||
mux.HandleFunc("/admin/api/namespaces/", s.authMiddleware(s.handleNamespaceDetail))
|
|
||||||
mux.HandleFunc("/admin/api/usage", s.authMiddleware(s.handleUsage))
|
|
||||||
mux.HandleFunc("/admin/api/users", s.authMiddleware(s.handleUsers))
|
|
||||||
}
|
|
||||||
|
|
||||||
// authMiddleware проверяет Bearer-токен из заголовка Authorization.
|
|
||||||
func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
auth := r.Header.Get("Authorization")
|
|
||||||
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != s.adminToken {
|
|
||||||
writeJSON(w, http.StatusUnauthorized, model.ErrResp("unauthorized"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next(w, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleNamespaces возвращает список клиентских namespace-ов (быстро, без счётчиков).
|
|
||||||
func (s *Server) handleNamespaces(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if s.k8s == nil {
|
|
||||||
writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ns, err := s.k8s.ListClientNamespaces(r.Context())
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, model.ErrResp(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"namespaces": ns})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleNamespaceDetail возвращает детальную информацию об одном namespace (с CRD-счётчиками).
|
|
||||||
func (s *Server) handleNamespaceDetail(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if s.k8s == nil {
|
|
||||||
writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// URL: /admin/api/namespaces/{name}
|
|
||||||
name := strings.TrimPrefix(r.URL.Path, "/admin/api/namespaces/")
|
|
||||||
if name == "" {
|
|
||||||
writeJSON(w, http.StatusBadRequest, model.ErrResp("missing namespace name"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
info, err := s.k8s.GetNamespaceDetail(ctx, name)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusNotFound, model.ErrResp(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, info)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleUsage возвращает агрегированные метрики использования для бухгалтерии.
|
|
||||||
func (s *Server) handleUsage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if s.k8s == nil {
|
|
||||||
writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
usage, err := s.k8s.CollectUsage(ctx)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, model.ErrResp(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"usage": usage})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleUsers возвращает список пользователей из secrets в клиентских namespace-ах.
|
|
||||||
func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if s.k8s == nil {
|
|
||||||
writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
users, err := s.k8s.ListUsers(ctx)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, model.ErrResp(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"users": users})
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
package metrics
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
|
||||||
"github.com/aws/aws-sdk-go-v2/config"
|
|
||||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
|
||||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
|
||||||
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
||||||
"k8s.io/client-go/dynamic"
|
|
||||||
"k8s.io/client-go/kubernetes"
|
|
||||||
"k8s.io/client-go/rest"
|
|
||||||
"k8s.io/client-go/tools/clientcmd"
|
|
||||||
|
|
||||||
"admin-console/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fission CRD group-version-resources.
|
|
||||||
var (
|
|
||||||
gvrFunctions = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "functions"}
|
|
||||||
gvrPackages = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "packages"}
|
|
||||||
gvrEnvironments = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "environments"}
|
|
||||||
gvrHTTPTriggers = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "httptriggers"}
|
|
||||||
gvrTimeTriggers = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "timetriggers"}
|
|
||||||
)
|
|
||||||
|
|
||||||
// K8sClient — обёртка над kubernetes.Clientset для нужд admin-console.
|
|
||||||
type K8sClient struct {
|
|
||||||
cs *kubernetes.Clientset
|
|
||||||
dc dynamic.Interface
|
|
||||||
s3 *s3.Client
|
|
||||||
s3Bucket string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewK8sClient создаёт клиента из in-cluster конфига, с fallback на ~/.kube/config.
|
|
||||||
func NewK8sClient() (*K8sClient, error) {
|
|
||||||
cfg, err := rest.InClusterConfig()
|
|
||||||
if err != nil {
|
|
||||||
cfg, err = clientcmd.BuildConfigFromFlags("", clientcmd.RecommendedHomeFile)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("no k8s config: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cs, err := kubernetes.NewForConfig(cfg)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
dc, err := dynamic.NewForConfig(cfg)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("dynamic client: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
k := &K8sClient{cs: cs, dc: dc}
|
|
||||||
|
|
||||||
// S3 — опционально (не падаем если нет кредов)
|
|
||||||
k.initS3()
|
|
||||||
|
|
||||||
return k, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// initS3 создаёт S3-клиент из переменных окружения.
|
|
||||||
func (k *K8sClient) initS3() {
|
|
||||||
endpoint := os.Getenv("S3_ENDPOINT")
|
|
||||||
bucket := os.Getenv("S3_BUCKET")
|
|
||||||
accessKey := os.Getenv("S3_ACCESS_KEY")
|
|
||||||
secretKey := os.Getenv("S3_SECRET_KEY")
|
|
||||||
|
|
||||||
if endpoint == "" || bucket == "" || accessKey == "" || secretKey == "" {
|
|
||||||
log.Println("[admin-console] S3 env vars not set — storage metrics disabled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := config.LoadDefaultConfig(context.Background(),
|
|
||||||
config.WithRegion("ru-msk-1"),
|
|
||||||
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[admin-console] S3 config error: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
k.s3 = s3.NewFromConfig(cfg, func(o *s3.Options) {
|
|
||||||
o.BaseEndpoint = aws.String(endpoint)
|
|
||||||
o.UsePathStyle = true
|
|
||||||
})
|
|
||||||
k.s3Bucket = bucket
|
|
||||||
log.Printf("[admin-console] S3 client ready, bucket=%s", bucket)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListClientNamespaces возвращает список namespace-ов БЕЗ CRD-счётчиков (быстро).
|
|
||||||
func (k *K8sClient) ListClientNamespaces(ctx context.Context) ([]model.NamespaceInfo, error) {
|
|
||||||
nsList, err := k.cs.CoreV1().Namespaces().List(ctx, metav1.ListOptions{
|
|
||||||
LabelSelector: "managed-by=fission-console",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
result := make([]model.NamespaceInfo, 0, len(nsList.Items))
|
|
||||||
for _, ns := range nsList.Items {
|
|
||||||
result = append(result, model.NamespaceInfo{
|
|
||||||
Name: ns.Name,
|
|
||||||
UserEmail: ns.Labels["user"],
|
|
||||||
CreatedAt: ns.CreationTimestamp.Format(time.RFC3339),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetNamespaceDetail возвращает информацию об одном namespace с CRD-счётчиками.
|
|
||||||
func (k *K8sClient) GetNamespaceDetail(ctx context.Context, nsName string) (*model.NamespaceInfo, error) {
|
|
||||||
ns, err := k.cs.CoreV1().Namespaces().Get(ctx, nsName, metav1.GetOptions{})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
info := &model.NamespaceInfo{
|
|
||||||
Name: ns.Name,
|
|
||||||
UserEmail: ns.Labels["user"],
|
|
||||||
CreatedAt: ns.CreationTimestamp.Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
k.fillCRDCounts(ctx, nsName, info)
|
|
||||||
return info, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// fillCRDCounts заполняет счётчики Fission-ресурсов для одного namespace.
|
|
||||||
func (k *K8sClient) fillCRDCounts(ctx context.Context, ns string, info *model.NamespaceInfo) {
|
|
||||||
count := func(gvr schema.GroupVersionResource) int {
|
|
||||||
list, err := k.dc.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{Limit: 1})
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if rem := list.GetRemainingItemCount(); rem != nil {
|
|
||||||
return int(*rem) + len(list.Items)
|
|
||||||
}
|
|
||||||
return len(list.Items)
|
|
||||||
}
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
wg.Add(5)
|
|
||||||
go func() { defer wg.Done(); info.Functions = count(gvrFunctions) }()
|
|
||||||
go func() { defer wg.Done(); info.Packages = count(gvrPackages) }()
|
|
||||||
go func() { defer wg.Done(); info.Environments = count(gvrEnvironments) }()
|
|
||||||
go func() { defer wg.Done(); info.HTTPTriggers = count(gvrHTTPTriggers) }()
|
|
||||||
go func() { defer wg.Done(); info.TimeTriggers = count(gvrTimeTriggers) }()
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
// CollectUsage возвращает агрегированную статистику по всем клиентским namespace-ам.
|
|
||||||
func (k *K8sClient) CollectUsage(ctx context.Context) ([]model.UsageEntry, error) {
|
|
||||||
nsList, err := k.cs.CoreV1().Namespaces().List(ctx, metav1.ListOptions{
|
|
||||||
LabelSelector: "managed-by=fission-console",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
result := make([]model.UsageEntry, 0, len(nsList.Items))
|
|
||||||
for _, ns := range nsList.Items {
|
|
||||||
entry := model.UsageEntry{
|
|
||||||
Namespace: ns.Name,
|
|
||||||
UserEmail: ns.Labels["user"],
|
|
||||||
PeriodStart: startOfMonth.Format("2006-01-02"),
|
|
||||||
PeriodEnd: now.Format("2006-01-02"),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Считаем функции через Dynamic client.
|
|
||||||
if list, err := k.dc.Resource(gvrFunctions).Namespace(ns.Name).List(ctx, metav1.ListOptions{Limit: 1}); err == nil {
|
|
||||||
entry.Functions = len(list.Items)
|
|
||||||
if rem := list.GetRemainingItemCount(); rem != nil {
|
|
||||||
entry.Functions += int(*rem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Считаем S3-хранилище.
|
|
||||||
entry.StorageGB = k.namespaceStorage(ctx, ns.Name)
|
|
||||||
|
|
||||||
// Invocations / CPU / Memory — TODO.
|
|
||||||
result = append(result, entry)
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// namespaceStorage возвращает суммарный размер объектов S3 с префиксом "{ns}/".
|
|
||||||
func (k *K8sClient) namespaceStorage(ctx context.Context, ns string) float64 {
|
|
||||||
if k.s3 == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
var totalBytes int64
|
|
||||||
paginator := s3.NewListObjectsV2Paginator(k.s3, &s3.ListObjectsV2Input{
|
|
||||||
Bucket: aws.String(k.s3Bucket),
|
|
||||||
Prefix: aws.String(ns + "/"),
|
|
||||||
})
|
|
||||||
|
|
||||||
for paginator.HasMorePages() {
|
|
||||||
page, err := paginator.NextPage(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[admin-console] S3 list error ns=%s: %v", ns, err)
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
for _, obj := range page.Contents {
|
|
||||||
if obj.Size != nil {
|
|
||||||
totalBytes += *obj.Size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return float64(totalBytes) / (1024 * 1024 * 1024)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListUsers собирает пользователей из secret fission-console-users в каждом
|
|
||||||
// клиентском namespace.
|
|
||||||
func (k *K8sClient) ListUsers(ctx context.Context) ([]model.UserInfo, error) {
|
|
||||||
nsList, err := k.cs.CoreV1().Namespaces().List(ctx, metav1.ListOptions{
|
|
||||||
LabelSelector: "managed-by=fission-console",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var users []model.UserInfo
|
|
||||||
for _, ns := range nsList.Items {
|
|
||||||
email := ns.Labels["user"]
|
|
||||||
if email == "" {
|
|
||||||
secret, err := k.cs.CoreV1().Secrets(ns.Name).Get(ctx, "fission-console-users", metav1.GetOptions{})
|
|
||||||
if err == nil {
|
|
||||||
for key := range secret.Data {
|
|
||||||
if strings.Contains(key, "@") {
|
|
||||||
email = key
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
users = append(users, model.UserInfo{
|
|
||||||
Email: email,
|
|
||||||
Namespace: ns.Name,
|
|
||||||
CreatedAt: ns.CreationTimestamp.Format(time.RFC3339),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return users, nil
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
// ErrResp — стандартный ответ с ошибкой.
|
|
||||||
func ErrResp(msg string) map[string]string {
|
|
||||||
return map[string]string{"error": msg}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NamespaceInfo — информация об одном клиентском namespace.
|
|
||||||
type NamespaceInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
UserEmail string `json:"user_email,omitempty"`
|
|
||||||
CreatedAt string `json:"created_at,omitempty"`
|
|
||||||
|
|
||||||
// счётчики ресурсов Fission
|
|
||||||
Functions int `json:"functions"`
|
|
||||||
Packages int `json:"packages"`
|
|
||||||
Environments int `json:"environments"`
|
|
||||||
HTTPTriggers int `json:"http_triggers"`
|
|
||||||
TimeTriggers int `json:"time_triggers"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UsageEntry — строка использования для одного namespace (для бухгалтерии).
|
|
||||||
type UsageEntry struct {
|
|
||||||
Namespace string `json:"namespace"`
|
|
||||||
UserEmail string `json:"user_email,omitempty"`
|
|
||||||
Functions int `json:"functions"`
|
|
||||||
Invocations int64 `json:"invocations_total"` // TODO: из Prometheus/logs
|
|
||||||
StorageGB float64 `json:"storage_gb"` // TODO: из storagesvc/S3
|
|
||||||
CPUMilliCores int64 `json:"cpu_millicores"` // TODO: из metrics-server
|
|
||||||
MemoryMB int64 `json:"memory_mb"` // TODO: из metrics-server
|
|
||||||
PeriodStart string `json:"period_start"`
|
|
||||||
PeriodEnd string `json:"period_end"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserInfo — пользователь из secret fission-console-users.
|
|
||||||
type UserInfo struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
Namespace string `json:"namespace"`
|
|
||||||
CreatedAt string `json:"created_at,omitempty"`
|
|
||||||
}
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Fission Admin Console v0.1.1</title>
|
|
||||||
<style>
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
body { font-family: system-ui, sans-serif; background: #0f1117; color: #e0e0e0; }
|
|
||||||
header { background: #1a1d27; padding: 16px 24px; border-bottom: 1px solid #2a2d3a;
|
|
||||||
display: flex; align-items: center; gap: 12px; }
|
|
||||||
header h1 { font-size: 18px; font-weight: 600; color: #fff; }
|
|
||||||
header .badge { background: #e53e3e; color: #fff; font-size: 10px; font-weight: 700;
|
|
||||||
padding: 2px 6px; border-radius: 4px; letter-spacing: .5px; }
|
|
||||||
#login { display: flex; align-items: center; justify-content: center; height: calc(100vh - 57px); }
|
|
||||||
#login form { background: #1a1d27; border: 1px solid #2a2d3a; border-radius: 8px;
|
|
||||||
padding: 32px; display: flex; flex-direction: column; gap: 12px; width: 320px; }
|
|
||||||
#login h2 { font-size: 16px; color: #fff; margin-bottom: 4px; }
|
|
||||||
input { background: #0f1117; border: 1px solid #2a2d3a; border-radius: 6px;
|
|
||||||
color: #e0e0e0; padding: 10px 12px; font-size: 14px; outline: none; }
|
|
||||||
input:focus { border-color: #4a90d9; }
|
|
||||||
button { background: #2563eb; color: #fff; border: none; border-radius: 6px;
|
|
||||||
padding: 10px; font-size: 14px; cursor: pointer; font-weight: 600; }
|
|
||||||
button:hover { background: #1d4ed8; }
|
|
||||||
#app { display: none; }
|
|
||||||
nav { background: #1a1d27; border-bottom: 1px solid #2a2d3a;
|
|
||||||
display: flex; gap: 4px; padding: 0 24px; }
|
|
||||||
nav button { background: none; border: none; color: #9ca3af; font-size: 14px;
|
|
||||||
padding: 12px 16px; cursor: pointer; border-bottom: 2px solid transparent; }
|
|
||||||
nav button.active { color: #fff; border-bottom-color: #2563eb; }
|
|
||||||
main { padding: 24px; }
|
|
||||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
||||||
th { color: #6b7280; font-weight: 500; text-align: left; padding: 8px 12px;
|
|
||||||
border-bottom: 1px solid #2a2d3a; }
|
|
||||||
td { padding: 10px 12px; border-bottom: 1px solid #1a1d27; }
|
|
||||||
tr:hover td { background: #1a1d27; }
|
|
||||||
.pill { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; }
|
|
||||||
.pill-blue { background: #1e3a5f; color: #60a5fa; }
|
|
||||||
.err { color: #f87171; font-size: 13px; padding: 16px 0; }
|
|
||||||
.loader { color: #6b7280; font-size: 13px; padding: 16px 0; }
|
|
||||||
.cnt { color: #6b7280; font-size: 12px; }
|
|
||||||
.cnt.loading { animation: pulse 1.5s infinite; }
|
|
||||||
@keyframes pulse { 0%,100%{opacity:.3} 50%{opacity:1} }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<h1>Fission Admin Console</h1>
|
|
||||||
<span class="badge">ADMIN</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div id="login">
|
|
||||||
<form id="loginForm">
|
|
||||||
<h2>Вход в Admin Console</h2>
|
|
||||||
<input type="password" id="tokenInput" placeholder="Admin token" autocomplete="current-password">
|
|
||||||
<button type="submit">Войти</button>
|
|
||||||
<div id="loginErr" class="err" style="display:none"></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="app">
|
|
||||||
<nav>
|
|
||||||
<button class="active" onclick="showTab('namespaces', this)">Клиенты</button>
|
|
||||||
<button onclick="showTab('usage', this)">Использование</button>
|
|
||||||
<button onclick="showTab('users', this)">Пользователи</button>
|
|
||||||
</nav>
|
|
||||||
<main id="content"></main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const API = '/admin/api';
|
|
||||||
let TOKEN = '';
|
|
||||||
let NS_LIST = [];
|
|
||||||
|
|
||||||
document.getElementById('loginForm').addEventListener('submit', async e => {
|
|
||||||
e.preventDefault();
|
|
||||||
TOKEN = document.getElementById('tokenInput').value.trim();
|
|
||||||
try {
|
|
||||||
const r = await fetch(`${API}/namespaces`, { headers: { Authorization: `Bearer ${TOKEN}` } });
|
|
||||||
if (r.ok) {
|
|
||||||
const data = await r.json();
|
|
||||||
NS_LIST = data.namespaces || [];
|
|
||||||
document.getElementById('login').style.display = 'none';
|
|
||||||
document.getElementById('app').style.display = 'block';
|
|
||||||
showTab('namespaces', document.querySelector('nav button'));
|
|
||||||
} else {
|
|
||||||
const err = document.getElementById('loginErr');
|
|
||||||
err.style.display = 'block';
|
|
||||||
err.textContent = 'Неверный токен';
|
|
||||||
}
|
|
||||||
} catch(e) {
|
|
||||||
const err = document.getElementById('loginErr');
|
|
||||||
err.style.display = 'block';
|
|
||||||
err.textContent = 'Ошибка соединения: ' + e.message;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function showTab(tab, btn) {
|
|
||||||
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
|
|
||||||
btn.classList.add('active');
|
|
||||||
loadTab(tab);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTab(tab) {
|
|
||||||
const content = document.getElementById('content');
|
|
||||||
if (tab === 'namespaces') {
|
|
||||||
content.innerHTML = renderNamespaces(NS_LIST);
|
|
||||||
// загружаем CRD-счётчики асинхронно
|
|
||||||
loadAllDetails();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
content.innerHTML = '<p class="loader">Загрузка…</p>';
|
|
||||||
try {
|
|
||||||
const r = await fetch(`${API}/${tab}`, { headers: { Authorization: `Bearer ${TOKEN}` } });
|
|
||||||
const data = await r.json();
|
|
||||||
if (!r.ok) { content.innerHTML = `<p class="err">${data.error}</p>`; return; }
|
|
||||||
content.innerHTML = renderTab(tab, data);
|
|
||||||
} catch(e) {
|
|
||||||
content.innerHTML = `<p class="err">Ошибка: ${e.message}</p>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTab(tab, data) {
|
|
||||||
if (tab === 'usage') return renderUsage(data.usage);
|
|
||||||
if (tab === 'users') return renderUsers(data.users);
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderNamespaces(rows) {
|
|
||||||
if (!rows?.length) return '<p class="loader">Нет namespace-ов</p>';
|
|
||||||
return `<table>
|
|
||||||
<tr><th>Namespace</th><th>Email</th><th>Fn</th><th>Pkg</th><th>Env</th><th>HTTP</th><th>Time</th><th>Создан</th></tr>
|
|
||||||
${rows.map((n,i) => `<tr>
|
|
||||||
<td><span class="pill pill-blue">${n.name}</span></td>
|
|
||||||
<td>${n.user_email || '—'}</td>
|
|
||||||
<td class="cnt loading" id="ns${i}-fn">…</td>
|
|
||||||
<td class="cnt loading" id="ns${i}-pkg">…</td>
|
|
||||||
<td class="cnt loading" id="ns${i}-env">…</td>
|
|
||||||
<td class="cnt loading" id="ns${i}-http">…</td>
|
|
||||||
<td class="cnt loading" id="ns${i}-time">…</td>
|
|
||||||
<td>${n.created_at ? n.created_at.slice(0,10) : '—'}</td>
|
|
||||||
</tr>`).join('')}
|
|
||||||
</table>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAllDetails() {
|
|
||||||
for (let i = 0; i < NS_LIST.length; i++) {
|
|
||||||
loadDetail(i, NS_LIST[i].name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDetail(idx, name) {
|
|
||||||
try {
|
|
||||||
const r = await fetch(`${API}/namespaces/${encodeURIComponent(name)}`, {
|
|
||||||
headers: { Authorization: `Bearer ${TOKEN}` }
|
|
||||||
});
|
|
||||||
if (!r.ok) return;
|
|
||||||
const d = await r.json();
|
|
||||||
setCnt(idx, 'fn', d.functions);
|
|
||||||
setCnt(idx, 'pkg', d.packages);
|
|
||||||
setCnt(idx, 'env', d.environments);
|
|
||||||
setCnt(idx, 'http', d.http_triggers);
|
|
||||||
setCnt(idx, 'time', d.time_triggers);
|
|
||||||
} catch(e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCnt(idx, type, val) {
|
|
||||||
const el = document.getElementById(`ns${idx}-${type}`);
|
|
||||||
if (el) { el.textContent = val ?? 0; el.classList.remove('loading'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderUsage(rows) {
|
|
||||||
if (!rows?.length) return '<p class="loader">Нет данных</p>';
|
|
||||||
const btn = `<div style="margin-bottom:12px">
|
|
||||||
<button onclick="exportCSV()">📥 Экспорт CSV</button>
|
|
||||||
</div>`;
|
|
||||||
return btn + `<table>
|
|
||||||
<tr><th>Namespace</th><th>Email</th><th>Функции</th><th>Вызовы</th><th>Хранилище GB</th><th>Период</th></tr>
|
|
||||||
${rows.map(u => `<tr>
|
|
||||||
<td><span class="pill pill-blue">${u.namespace}</span></td>
|
|
||||||
<td>${u.user_email || '—'}</td>
|
|
||||||
<td>${u.functions}</td>
|
|
||||||
<td>${u.invocations_total}</td>
|
|
||||||
<td>${(u.storage_gb || 0).toFixed(3)}</td>
|
|
||||||
<td>${u.period_start} — ${u.period_end}</td>
|
|
||||||
</tr>`).join('')}
|
|
||||||
</table>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportCSV() {
|
|
||||||
const table = document.querySelector('table');
|
|
||||||
if (!table) return;
|
|
||||||
let csv = '\uFEFF';
|
|
||||||
table.querySelectorAll('tr').forEach(row => {
|
|
||||||
const cells = [];
|
|
||||||
row.querySelectorAll('th,td').forEach(c => cells.push('"' + c.textContent.replace(/"/g, '""') + '"'));
|
|
||||||
csv += cells.join(',') + '\n';
|
|
||||||
});
|
|
||||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = URL.createObjectURL(blob);
|
|
||||||
a.download = `usage-${new Date().toISOString().slice(0,10)}.csv`;
|
|
||||||
a.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderUsers(rows) {
|
|
||||||
if (!rows?.length) return '<p class="loader">Нет пользователей</p>';
|
|
||||||
return `<table>
|
|
||||||
<tr><th>Email</th><th>Namespace</th><th>Создан</th></tr>
|
|
||||||
${rows.map(u => `<tr>
|
|
||||||
<td>${u.email || '—'}</td>
|
|
||||||
<td><span class="pill pill-blue">${u.namespace}</span></td>
|
|
||||||
<td>${u.created_at ? u.created_at.slice(0,10) : '—'}</td>
|
|
||||||
</tr>`).join('')}
|
|
||||||
</table>`;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,114 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
#!/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 "════════════════════════════════════════════════════════════════"
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
def main(event, context):
|
|
||||||
return {
|
|
||||||
"statusCode": 200,
|
|
||||||
"body": "hello, world!"
|
|
||||||
}
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
// Package api — клонирование функций.
|
|
||||||
//
|
|
||||||
// handleCloneFunction создаёт полную копию функции с новым именем:
|
|
||||||
// - скачивает архив из storagesvc (или копирует literal)
|
|
||||||
// - заливает новый архив (отдельный объект в S3)
|
|
||||||
// - создаёт новый Package, Function и HTTPTrigger
|
|
||||||
//
|
|
||||||
// Архив переливается заново, чтобы удаление оригинала не сломало клон.
|
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"fission-console/internal/billing"
|
|
||||||
"fission-console/internal/fission"
|
|
||||||
"fission-console/internal/runtime"
|
|
||||||
|
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleCloneFunction — POST /functions/:name/clone
|
|
||||||
// Body: {"new_name": "my-clone", "route": "/my-clone"} (route необязателен)
|
|
||||||
func (s *Server) handleCloneFunction(w http.ResponseWriter, r *http.Request, srcName string) {
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
ns := s.userNS(r)
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
NewName string `json:"new_name"`
|
|
||||||
Route string `json:"route"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.NewName = strings.TrimSpace(req.NewName)
|
|
||||||
req.Route = strings.TrimSpace(req.Route)
|
|
||||||
|
|
||||||
// Валидация нового имени
|
|
||||||
if req.NewName == "" {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, "new_name is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
validName := regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
|
|
||||||
if !validName.MatchString(req.NewName) || len(req.NewName) > 57 {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, "invalid new_name: must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ and be <= 57 chars")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получаем исходную функцию
|
|
||||||
srcFn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, srcName, metav1.GetOptions{})
|
|
||||||
if err != nil {
|
|
||||||
if apierrors.IsNotFound(err) {
|
|
||||||
writeJSONError(w, http.StatusNotFound, fmt.Sprintf("function %q not found", srcName))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получаем имя пакета исходной функции
|
|
||||||
srcPkgName, _, _ := unstructured.NestedString(srcFn.Object, "spec", "package", "packageref", "name")
|
|
||||||
if srcPkgName == "" {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, "source function has no package reference")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получаем исходный Package
|
|
||||||
srcPkg, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, srcPkgName, metav1.GetOptions{})
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get source package: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Скачиваем байты архива из Package
|
|
||||||
archiveBytes, err := s.downloadPackageBytes(ctx, srcPkg)
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("download archive: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загружаем как новый архив
|
|
||||||
newDeploySpec, err := s.buildDeploySpec(ctx, archiveBytes)
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("upload clone archive: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Параметры для нового пакета (берём spec из оригинала)
|
|
||||||
now := time.Now().UTC()
|
|
||||||
newPkgName := req.NewName + "-" + now.Format("20060102150405")
|
|
||||||
|
|
||||||
// Определяем environment из исходной функции
|
|
||||||
envName, _, _ := unstructured.NestedString(srcFn.Object, "spec", "environment", "name")
|
|
||||||
|
|
||||||
// Собираем spec пакета (аналогично оригиналу, но с новыми байтами)
|
|
||||||
// Если у оригинала есть source (Go) — копируем source spec
|
|
||||||
srcSourceSpec, _, _ := unstructured.NestedMap(srcPkg.Object, "spec", "source")
|
|
||||||
hasBuildCmd, _, _ := unstructured.NestedString(srcPkg.Object, "spec", "buildcommand")
|
|
||||||
|
|
||||||
var newPkgSpec map[string]any
|
|
||||||
if hasBuildCmd != "" {
|
|
||||||
// Go: source package
|
|
||||||
newPkgSpec = map[string]any{
|
|
||||||
"source": newDeploySpec, // перезаливаем source
|
|
||||||
"deployment": map[string]any{},
|
|
||||||
"environment": map[string]any{"name": envName, "namespace": ns},
|
|
||||||
"buildcommand": hasBuildCmd,
|
|
||||||
}
|
|
||||||
_ = srcSourceSpec
|
|
||||||
} else {
|
|
||||||
newPkgSpec = map[string]any{
|
|
||||||
"deployment": newDeploySpec,
|
|
||||||
"environment": map[string]any{"name": envName, "namespace": ns},
|
|
||||||
"source": map[string]any{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
newPkg := &unstructured.Unstructured{Object: map[string]any{
|
|
||||||
"apiVersion": "fission.io/v1",
|
|
||||||
"kind": "Package",
|
|
||||||
"metadata": map[string]any{"name": newPkgName, "namespace": ns},
|
|
||||||
"spec": newPkgSpec,
|
|
||||||
}}
|
|
||||||
if _, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Create(ctx, newPkg, metav1.CreateOptions{}); err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create clone package: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Копируем аннотации из исходной функции
|
|
||||||
srcAnnotations := srcFn.GetAnnotations()
|
|
||||||
newAnnotations := map[string]any{
|
|
||||||
functionCreatedAtAnnotation: now.Format(time.RFC3339),
|
|
||||||
functionUpdatedAtAnnotation: now.Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
for _, k := range []string{
|
|
||||||
"fission-console/language",
|
|
||||||
fissionSourceTypeAnnotation,
|
|
||||||
"fission-console/env-vars",
|
|
||||||
"fission-console/archive-filename",
|
|
||||||
} {
|
|
||||||
if v, ok := srcAnnotations[k]; ok && v != "" {
|
|
||||||
newAnnotations[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
newAnnotations["fission-console/cloned-from"] = srcName
|
|
||||||
|
|
||||||
// Копируем entrypoint
|
|
||||||
entrypoint, _, _ := unstructured.NestedString(srcFn.Object, "spec", "package", "functionName")
|
|
||||||
timeout, _, _ := unstructured.NestedInt64(srcFn.Object, "spec", "functionTimeout")
|
|
||||||
if timeout == 0 {
|
|
||||||
timeout = 60
|
|
||||||
}
|
|
||||||
|
|
||||||
// Копируем InvokeStrategy и podspec
|
|
||||||
invokeStrategy, _, _ := unstructured.NestedMap(srcFn.Object, "spec", "InvokeStrategy")
|
|
||||||
if invokeStrategy == nil {
|
|
||||||
invokeStrategy = map[string]any{
|
|
||||||
"ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"},
|
|
||||||
"StrategyType": "execution",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
podspec, _, _ := unstructured.NestedMap(srcFn.Object, "spec", "podspec")
|
|
||||||
|
|
||||||
newFnSpec := map[string]any{
|
|
||||||
"environment": map[string]any{"name": envName, "namespace": ns},
|
|
||||||
"functionTimeout": timeout,
|
|
||||||
"InvokeStrategy": invokeStrategy,
|
|
||||||
"package": map[string]any{
|
|
||||||
"packageref": map[string]any{"name": newPkgName, "namespace": ns},
|
|
||||||
"functionName": entrypoint,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if len(podspec) > 0 {
|
|
||||||
newFnSpec["podspec"] = podspec
|
|
||||||
}
|
|
||||||
|
|
||||||
newFn := &unstructured.Unstructured{Object: map[string]any{
|
|
||||||
"apiVersion": "fission.io/v1",
|
|
||||||
"kind": "Function",
|
|
||||||
"metadata": map[string]any{
|
|
||||||
"name": req.NewName,
|
|
||||||
"namespace": ns,
|
|
||||||
"annotations": newAnnotations,
|
|
||||||
},
|
|
||||||
"spec": newFnSpec,
|
|
||||||
}}
|
|
||||||
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Create(ctx, newFn, metav1.CreateOptions{}); err != nil {
|
|
||||||
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
|
|
||||||
if apierrors.IsAlreadyExists(err) {
|
|
||||||
writeJSONError(w, http.StatusConflict, fmt.Sprintf("function %q already exists", req.NewName))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create clone function: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Маршрут для нового триггера
|
|
||||||
if req.Route == "" {
|
|
||||||
nsShort := ns
|
|
||||||
if len(nsShort) > 12 {
|
|
||||||
nsShort = nsShort[len(nsShort)-12:]
|
|
||||||
}
|
|
||||||
req.Route = "/" + nsShort + "/" + req.NewName
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(req.Route, "/") {
|
|
||||||
req.Route = "/" + req.Route
|
|
||||||
}
|
|
||||||
triggerName := req.NewName + "-route"
|
|
||||||
|
|
||||||
// Определяем методы из существующего триггера оригинала
|
|
||||||
methods := s.getTriggerMethods(ctx, ns, srcName)
|
|
||||||
if len(methods) == 0 {
|
|
||||||
methods = []any{"GET"}
|
|
||||||
}
|
|
||||||
|
|
||||||
newTrigger := &unstructured.Unstructured{Object: map[string]any{
|
|
||||||
"apiVersion": "fission.io/v1",
|
|
||||||
"kind": "HTTPTrigger",
|
|
||||||
"metadata": map[string]any{"name": triggerName, "namespace": ns},
|
|
||||||
"spec": map[string]any{
|
|
||||||
"relativeurl": req.Route,
|
|
||||||
"methods": methods,
|
|
||||||
"createingress": true,
|
|
||||||
"functionref": map[string]any{"type": "name", "name": req.NewName},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
if _, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Create(ctx, newTrigger, metav1.CreateOptions{}); err != nil {
|
|
||||||
_ = s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Delete(ctx, req.NewName, metav1.DeleteOptions{})
|
|
||||||
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create httptrigger: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.billing.RecordInvocation(billing.Invocation{
|
|
||||||
Namespace: ns,
|
|
||||||
FunctionName: req.NewName,
|
|
||||||
TriggerType: billing.TriggerEvent,
|
|
||||||
StartedAt: now,
|
|
||||||
StatusCode: http.StatusCreated,
|
|
||||||
RecordedBy: "console",
|
|
||||||
EventType: "clone",
|
|
||||||
})
|
|
||||||
|
|
||||||
writeAnyJSON(w, http.StatusCreated, map[string]any{
|
|
||||||
"name": req.NewName,
|
|
||||||
"cloned_from": srcName,
|
|
||||||
"package": newPkgName,
|
|
||||||
"route": req.Route,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// downloadPackageBytes извлекает байты архива из Package CRD.
|
|
||||||
// Поддерживает type:url (скачивает из storagesvc) и type:literal (base64).
|
|
||||||
func (s *Server) downloadPackageBytes(ctx context.Context, pkg *unstructured.Unstructured) ([]byte, error) {
|
|
||||||
// Пробуем deployment сначала, потом source (для Go)
|
|
||||||
for _, field := range [][]string{{"spec", "deployment"}, {"spec", "source"}} {
|
|
||||||
spec, _, _ := unstructured.NestedMap(pkg.Object, field...)
|
|
||||||
if len(spec) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
archiveType, _ := spec["type"].(string)
|
|
||||||
switch archiveType {
|
|
||||||
case "url":
|
|
||||||
archiveURL, _ := spec["url"].(string)
|
|
||||||
if archiveURL == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return s.downloadFromStoragesvc(ctx, archiveURL)
|
|
||||||
case "literal":
|
|
||||||
lit, _ := spec["literal"].(string)
|
|
||||||
if lit == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return base64.StdEncoding.DecodeString(lit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Последний шанс: если функция Python с простым кодом — возвращаем заглушку
|
|
||||||
return nil, fmt.Errorf("no downloadable archive found in package (empty deployment and source spec)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// downloadFromStoragesvc скачивает архив по URL из storagesvc.
|
|
||||||
func (s *Server) downloadFromStoragesvc(ctx context.Context, archiveURL string) ([]byte, error) {
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, archiveURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("build download request: %w", err)
|
|
||||||
}
|
|
||||||
resp, err := s.http.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("download archive: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("download archive status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read archive body: %w", err)
|
|
||||||
}
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getTriggerMethods возвращает методы HTTP из триггера функции (для копирования в клон).
|
|
||||||
func (s *Server) getTriggerMethods(ctx context.Context, ns, fnName string) []any {
|
|
||||||
triggers, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, t := range triggers.Items {
|
|
||||||
ref, _, _ := unstructured.NestedString(t.Object, "spec", "functionref", "name")
|
|
||||||
if ref != fnName {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
methods, _, _ := unstructured.NestedSlice(t.Object, "spec", "methods")
|
|
||||||
if len(methods) > 0 {
|
|
||||||
return methods
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ссылка на runtime.DefaultEntrypoint для возможного использования в будущем
|
|
||||||
var _ = runtime.DefaultEntrypoint
|
|
||||||
@@ -1,631 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/zip"
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"fission-console/internal/billing"
|
|
||||||
"fission-console/internal/fission"
|
|
||||||
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
||||||
k8sruntime "k8s.io/apimachinery/pkg/runtime"
|
|
||||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
||||||
dynamicfake "k8s.io/client-go/dynamic/fake"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// newCloneTestServer создаёт Server с фиктивным k8s-клиентом и
|
|
||||||
// опциональным HTTP-сервером, имитирующим storagesvc.
|
|
||||||
func newCloneTestServer(t *testing.T, storagesvcHandler http.HandlerFunc, objs ...*unstructured.Unstructured) (*Server, *httptest.Server) {
|
|
||||||
t.Helper()
|
|
||||||
scheme := k8sruntime.NewScheme()
|
|
||||||
listKinds := map[schema.GroupVersionResource]string{
|
|
||||||
fission.FunctionGVR: "FunctionList",
|
|
||||||
fission.PackageGVR: "PackageList",
|
|
||||||
fission.HTTPTrigGVR: "HTTPTriggerList",
|
|
||||||
}
|
|
||||||
runtimeObjs := make([]k8sruntime.Object, len(objs))
|
|
||||||
for i, o := range objs {
|
|
||||||
runtimeObjs[i] = o
|
|
||||||
}
|
|
||||||
dynClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, runtimeObjs...)
|
|
||||||
|
|
||||||
var storagesvc *httptest.Server
|
|
||||||
httpClient := &http.Client{}
|
|
||||||
if storagesvcHandler != nil {
|
|
||||||
storagesvc = httptest.NewServer(storagesvcHandler)
|
|
||||||
httpClient = storagesvc.Client()
|
|
||||||
}
|
|
||||||
|
|
||||||
s := &Server{
|
|
||||||
dyn: dynClient,
|
|
||||||
ns: "fission-test",
|
|
||||||
http: httpClient,
|
|
||||||
billing: billing.NoopStore{},
|
|
||||||
}
|
|
||||||
return s, storagesvc
|
|
||||||
}
|
|
||||||
|
|
||||||
// cloneRequest выполняет POST /functions/:srcName/clone с телом body.
|
|
||||||
func cloneRequest(t *testing.T, s *Server, srcName, body string) *httptest.ResponseRecorder {
|
|
||||||
t.Helper()
|
|
||||||
req := httptest.NewRequest(http.MethodPost,
|
|
||||||
"/console/api/functions/"+srcName+"/clone",
|
|
||||||
strings.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("X-Auth-Token", "test-token")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
s.handleCloneFunction(rec, req, srcName)
|
|
||||||
return rec
|
|
||||||
}
|
|
||||||
|
|
||||||
// makeZip возвращает минимальный zip-архив с одним файлом filename/content.
|
|
||||||
func makeZip(t *testing.T, filename, content string) []byte {
|
|
||||||
t.Helper()
|
|
||||||
var buf bytes.Buffer
|
|
||||||
w := zip.NewWriter(&buf)
|
|
||||||
f, err := w.Create(filename)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("zip.Create: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := io.WriteString(f, content); err != nil {
|
|
||||||
t.Fatalf("zip.Write: %v", err)
|
|
||||||
}
|
|
||||||
if err := w.Close(); err != nil {
|
|
||||||
t.Fatalf("zip.Close: %v", err)
|
|
||||||
}
|
|
||||||
return buf.Bytes()
|
|
||||||
}
|
|
||||||
|
|
||||||
// packageWithLiteral создаёт Package CRD с type:literal (base64-encoded bytes).
|
|
||||||
func packageWithLiteral(ns, name, fnName string, data []byte) *unstructured.Unstructured {
|
|
||||||
return &unstructured.Unstructured{Object: map[string]any{
|
|
||||||
"apiVersion": "fission.io/v1",
|
|
||||||
"kind": "Package",
|
|
||||||
"metadata": map[string]any{"name": name, "namespace": ns},
|
|
||||||
"spec": map[string]any{
|
|
||||||
"deployment": map[string]any{
|
|
||||||
"type": "literal",
|
|
||||||
"literal": base64.StdEncoding.EncodeToString(data),
|
|
||||||
},
|
|
||||||
"environment": map[string]any{"name": fnName, "namespace": ns},
|
|
||||||
"source": map[string]any{},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// packageWithURL создаёт Package CRD с type:url.
|
|
||||||
func packageWithURL(ns, pkgName, envName, archiveURL string) *unstructured.Unstructured {
|
|
||||||
return &unstructured.Unstructured{Object: map[string]any{
|
|
||||||
"apiVersion": "fission.io/v1",
|
|
||||||
"kind": "Package",
|
|
||||||
"metadata": map[string]any{"name": pkgName, "namespace": ns},
|
|
||||||
"spec": map[string]any{
|
|
||||||
"deployment": map[string]any{
|
|
||||||
"type": "url",
|
|
||||||
"url": archiveURL,
|
|
||||||
},
|
|
||||||
"environment": map[string]any{"name": envName, "namespace": ns},
|
|
||||||
"source": map[string]any{},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// functionWithPkg создаёт Function CRD, ссылающуюся на pkgName.
|
|
||||||
func functionWithPkg(ns, fnName, pkgName, envName, entrypoint string) *unstructured.Unstructured {
|
|
||||||
return &unstructured.Unstructured{Object: map[string]any{
|
|
||||||
"apiVersion": "fission.io/v1",
|
|
||||||
"kind": "Function",
|
|
||||||
"metadata": map[string]any{
|
|
||||||
"name": fnName,
|
|
||||||
"namespace": ns,
|
|
||||||
"annotations": map[string]any{
|
|
||||||
"fission-console/language": envName,
|
|
||||||
"fission-console/source-type": "code",
|
|
||||||
"fission-console/created-at": "2026-05-09T00:00:00Z",
|
|
||||||
"fission-console/updated-at": "2026-05-09T00:00:00Z",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"spec": map[string]any{
|
|
||||||
"environment": map[string]any{"name": envName, "namespace": ns},
|
|
||||||
"functionTimeout": int64(120),
|
|
||||||
"package": map[string]any{
|
|
||||||
"packageref": map[string]any{"name": pkgName, "namespace": ns},
|
|
||||||
"functionName": entrypoint,
|
|
||||||
},
|
|
||||||
"InvokeStrategy": map[string]any{
|
|
||||||
"ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"},
|
|
||||||
"StrategyType": "execution",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// httpTriggerObject создаёт HTTPTrigger CRD для функции fnName.
|
|
||||||
func httpTriggerObject(ns, trigName, fnName, route string, methods []any) *unstructured.Unstructured {
|
|
||||||
return &unstructured.Unstructured{Object: map[string]any{
|
|
||||||
"apiVersion": "fission.io/v1",
|
|
||||||
"kind": "HTTPTrigger",
|
|
||||||
"metadata": map[string]any{"name": trigName, "namespace": ns},
|
|
||||||
"spec": map[string]any{
|
|
||||||
"relativeurl": route,
|
|
||||||
"methods": methods,
|
|
||||||
"createingress": true,
|
|
||||||
"functionref": map[string]any{"type": "name", "name": fnName},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Тесты валидации имени ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func TestHandleCloneFunction_InvalidName_EmptyName(t *testing.T) {
|
|
||||||
s, _ := newCloneTestServer(t, nil)
|
|
||||||
rec := cloneRequest(t, s, "src", `{"new_name":""}`)
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(rec.Body.String(), "new_name is required") {
|
|
||||||
t.Fatalf("body should mention 'new_name is required', got: %s", rec.Body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleCloneFunction_InvalidName_UpperCase(t *testing.T) {
|
|
||||||
s, _ := newCloneTestServer(t, nil)
|
|
||||||
rec := cloneRequest(t, s, "src", `{"new_name":"MyFunc"}`)
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleCloneFunction_InvalidName_StartsWithDash(t *testing.T) {
|
|
||||||
s, _ := newCloneTestServer(t, nil)
|
|
||||||
rec := cloneRequest(t, s, "src", `{"new_name":"-bad"}`)
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleCloneFunction_InvalidName_EndsWithDash(t *testing.T) {
|
|
||||||
s, _ := newCloneTestServer(t, nil)
|
|
||||||
rec := cloneRequest(t, s, "src", `{"new_name":"bad-"}`)
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleCloneFunction_InvalidName_TooLong(t *testing.T) {
|
|
||||||
s, _ := newCloneTestServer(t, nil)
|
|
||||||
longName := strings.Repeat("a", 58)
|
|
||||||
rec := cloneRequest(t, s, "src", fmt.Sprintf(`{"new_name":%q}`, longName))
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleCloneFunction_ValidName_MaxLength(t *testing.T) {
|
|
||||||
// 57 символов — допустимо; но функция src не существует → 404
|
|
||||||
s, _ := newCloneTestServer(t, nil)
|
|
||||||
name57 := strings.Repeat("a", 57)
|
|
||||||
rec := cloneRequest(t, s, "src", fmt.Sprintf(`{"new_name":%q}`, name57))
|
|
||||||
// Ожидаем не 400 (прошло валидацию), а 404 (src не найден)
|
|
||||||
if rec.Code == http.StatusBadRequest {
|
|
||||||
t.Fatalf("57-char name should pass validation, got 400; body=%s", rec.Body)
|
|
||||||
}
|
|
||||||
if rec.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleCloneFunction_ValidName_WithDashes(t *testing.T) {
|
|
||||||
// Имя вида "a-b-c" — допустимо
|
|
||||||
s, _ := newCloneTestServer(t, nil)
|
|
||||||
rec := cloneRequest(t, s, "nonexist", `{"new_name":"a-b-c"}`)
|
|
||||||
if rec.Code == http.StatusBadRequest {
|
|
||||||
t.Fatalf("'a-b-c' should pass validation, got 400; body=%s", rec.Body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Тест: источник не существует ────────────────────────────────────────────
|
|
||||||
|
|
||||||
func TestHandleCloneFunction_SourceNotFound(t *testing.T) {
|
|
||||||
s, _ := newCloneTestServer(t, nil)
|
|
||||||
rec := cloneRequest(t, s, "ghost", `{"new_name":"ghost-copy"}`)
|
|
||||||
if rec.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(rec.Body.String(), "ghost") {
|
|
||||||
t.Fatalf("body should mention 'ghost', got: %s", rec.Body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Тест: успешный клон с type:literal ──────────────────────────────────────
|
|
||||||
|
|
||||||
func TestHandleCloneFunction_Success_Literal(t *testing.T) {
|
|
||||||
const ns = "fission-test"
|
|
||||||
zipData := makeZip(t, "main.py", "def main():\n return 'hello'\n")
|
|
||||||
|
|
||||||
pkg := packageWithLiteral(ns, "src-pkg", "python", zipData)
|
|
||||||
fn := functionWithPkg(ns, "src", "src-pkg", "python", "main.main")
|
|
||||||
trig := httpTriggerObject(ns, "src-route", "src", "/test-ns/src", []any{"GET", "POST"})
|
|
||||||
|
|
||||||
// storagesvc принимает загрузку и возвращает URL
|
|
||||||
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method == http.MethodPost {
|
|
||||||
// Имитируем загрузку — возвращаем ID архива
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
fmt.Fprint(w, `{"id":"fission/new-archive-id-123"}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}))
|
|
||||||
defer storagesvc.Close()
|
|
||||||
|
|
||||||
s, _ := newCloneTestServer(t, nil, pkg, fn, trig)
|
|
||||||
s.storagesvcURL = storagesvc.URL
|
|
||||||
s.http = storagesvc.Client()
|
|
||||||
|
|
||||||
rec := cloneRequest(t, s, "src", `{"new_name":"src-copy"}`)
|
|
||||||
if rec.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp map[string]any
|
|
||||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
||||||
t.Fatalf("decode response: %v", err)
|
|
||||||
}
|
|
||||||
if resp["name"] != "src-copy" {
|
|
||||||
t.Fatalf("response.name = %v, want 'src-copy'", resp["name"])
|
|
||||||
}
|
|
||||||
if resp["cloned_from"] != "src" {
|
|
||||||
t.Fatalf("response.cloned_from = %v, want 'src'", resp["cloned_from"])
|
|
||||||
}
|
|
||||||
if resp["package"] == "" || resp["package"] == nil {
|
|
||||||
t.Fatalf("response.package is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Проверяем что Function создана
|
|
||||||
cloneFn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, "src-copy", metav1.GetOptions{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("clone Function not found: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Аннотация cloned-from
|
|
||||||
ann := cloneFn.GetAnnotations()
|
|
||||||
if ann["fission-console/cloned-from"] != "src" {
|
|
||||||
t.Fatalf("annotation cloned-from = %q, want 'src'", ann["fission-console/cloned-from"])
|
|
||||||
}
|
|
||||||
// Аннотации language и source-type скопированы
|
|
||||||
if ann["fission-console/language"] != "python" {
|
|
||||||
t.Fatalf("annotation language = %q, want 'python'", ann["fission-console/language"])
|
|
||||||
}
|
|
||||||
if ann["fission-console/source-type"] != "code" {
|
|
||||||
t.Fatalf("annotation source-type = %q, want 'code'", ann["fission-console/source-type"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Entrypoint скопирован
|
|
||||||
ep, _, _ := unstructured.NestedString(cloneFn.Object, "spec", "package", "functionName")
|
|
||||||
if ep != "main.main" {
|
|
||||||
t.Fatalf("entrypoint = %q, want 'main.main'", ep)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timeout скопирован
|
|
||||||
timeout, _, _ := unstructured.NestedInt64(cloneFn.Object, "spec", "functionTimeout")
|
|
||||||
if timeout != 120 {
|
|
||||||
t.Fatalf("timeout = %d, want 120", timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем что HTTPTrigger создан
|
|
||||||
trigName := "src-copy-route"
|
|
||||||
cloneTrig, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Get(ctx, trigName, metav1.GetOptions{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("clone HTTPTrigger not found: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Методы скопированы из оригинального триггера
|
|
||||||
methods, _, _ := unstructured.NestedStringSlice(cloneTrig.Object, "spec", "methods")
|
|
||||||
if len(methods) != 2 {
|
|
||||||
t.Fatalf("methods = %v, want [GET POST]", methods)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Маршрут сгенерирован автоматически (содержит new_name)
|
|
||||||
route, _, _ := unstructured.NestedString(cloneTrig.Object, "spec", "relativeurl")
|
|
||||||
if !strings.HasSuffix(route, "/src-copy") {
|
|
||||||
t.Fatalf("route = %q, should end with '/src-copy'", route)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Тест: маршрут задан явно ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func TestHandleCloneFunction_CustomRoute(t *testing.T) {
|
|
||||||
const ns = "fission-test"
|
|
||||||
zipData := makeZip(t, "main.py", "def main():\n return 'hi'\n")
|
|
||||||
|
|
||||||
pkg := packageWithLiteral(ns, "fn-pkg", "python", zipData)
|
|
||||||
fn := functionWithPkg(ns, "fn", "fn-pkg", "python", "main.main")
|
|
||||||
|
|
||||||
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
fmt.Fprint(w, `{"id":"fission/arc-456"}`)
|
|
||||||
}))
|
|
||||||
defer storagesvc.Close()
|
|
||||||
|
|
||||||
s, _ := newCloneTestServer(t, nil, pkg, fn)
|
|
||||||
s.storagesvcURL = storagesvc.URL
|
|
||||||
s.http = storagesvc.Client()
|
|
||||||
|
|
||||||
rec := cloneRequest(t, s, "fn", `{"new_name":"fn-clone","route":"/custom/path"}`)
|
|
||||||
if rec.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
trig, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Get(ctx, "fn-clone-route", metav1.GetOptions{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("HTTPTrigger not found: %v", err)
|
|
||||||
}
|
|
||||||
route, _, _ := unstructured.NestedString(trig.Object, "spec", "relativeurl")
|
|
||||||
if route != "/custom/path" {
|
|
||||||
t.Fatalf("route = %q, want '/custom/path'", route)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Тест: маршрут без слеша — добавляется автоматически ─────────────────────
|
|
||||||
|
|
||||||
func TestHandleCloneFunction_RouteWithoutLeadingSlash(t *testing.T) {
|
|
||||||
const ns = "fission-test"
|
|
||||||
zipData := makeZip(t, "main.py", "def main():\n return 'hi'\n")
|
|
||||||
|
|
||||||
pkg := packageWithLiteral(ns, "fn2-pkg", "python", zipData)
|
|
||||||
fn := functionWithPkg(ns, "fn2", "fn2-pkg", "python", "main.main")
|
|
||||||
|
|
||||||
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
fmt.Fprint(w, `{"id":"fission/arc-789"}`)
|
|
||||||
}))
|
|
||||||
defer storagesvc.Close()
|
|
||||||
|
|
||||||
s, _ := newCloneTestServer(t, nil, pkg, fn)
|
|
||||||
s.storagesvcURL = storagesvc.URL
|
|
||||||
s.http = storagesvc.Client()
|
|
||||||
|
|
||||||
rec := cloneRequest(t, s, "fn2", `{"new_name":"fn2-clone","route":"no-leading-slash"}`)
|
|
||||||
if rec.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
trig, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Get(ctx, "fn2-clone-route", metav1.GetOptions{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("HTTPTrigger not found: %v", err)
|
|
||||||
}
|
|
||||||
route, _, _ := unstructured.NestedString(trig.Object, "spec", "relativeurl")
|
|
||||||
if !strings.HasPrefix(route, "/") {
|
|
||||||
t.Fatalf("route = %q, should start with '/'", route)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Тест: дублирующее имя → 409 ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
func TestHandleCloneFunction_DuplicateName_Conflict(t *testing.T) {
|
|
||||||
const ns = "fission-test"
|
|
||||||
zipData := makeZip(t, "main.py", "def main():\n return 'a'\n")
|
|
||||||
|
|
||||||
pkg := packageWithLiteral(ns, "dup-pkg", "python", zipData)
|
|
||||||
fn := functionWithPkg(ns, "dup-src", "dup-pkg", "python", "main.main")
|
|
||||||
// Уже существующая функция с именем "dup-copy"
|
|
||||||
existingFn := functionWithPkg(ns, "dup-copy", "other-pkg", "python", "main.main")
|
|
||||||
|
|
||||||
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
fmt.Fprint(w, `{"id":"fission/arc-dup"}`)
|
|
||||||
}))
|
|
||||||
defer storagesvc.Close()
|
|
||||||
|
|
||||||
s, _ := newCloneTestServer(t, nil, pkg, fn, existingFn)
|
|
||||||
s.storagesvcURL = storagesvc.URL
|
|
||||||
s.http = storagesvc.Client()
|
|
||||||
|
|
||||||
rec := cloneRequest(t, s, "dup-src", `{"new_name":"dup-copy"}`)
|
|
||||||
if rec.Code != http.StatusConflict {
|
|
||||||
t.Fatalf("status = %d, want 409; body=%s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(rec.Body.String(), "dup-copy") {
|
|
||||||
t.Fatalf("body should mention 'dup-copy', got: %s", rec.Body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Тест: откат при ошибке создания Function ────────────────────────────────
|
|
||||||
|
|
||||||
func TestHandleCloneFunction_Rollback_OnFunctionConflict(t *testing.T) {
|
|
||||||
const ns = "fission-test"
|
|
||||||
zipData := makeZip(t, "main.py", "def main():\n return 'rb'\n")
|
|
||||||
|
|
||||||
pkg := packageWithLiteral(ns, "rb-pkg", "python", zipData)
|
|
||||||
fn := functionWithPkg(ns, "rb-src", "rb-pkg", "python", "main.main")
|
|
||||||
// Уже существующая функция с именем клона
|
|
||||||
existingFn := functionWithPkg(ns, "rb-clone", "other-pkg", "python", "main.main")
|
|
||||||
|
|
||||||
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
fmt.Fprint(w, `{"id":"fission/arc-rb"}`)
|
|
||||||
}))
|
|
||||||
defer storagesvc.Close()
|
|
||||||
|
|
||||||
s, _ := newCloneTestServer(t, nil, pkg, fn, existingFn)
|
|
||||||
s.storagesvcURL = storagesvc.URL
|
|
||||||
s.http = storagesvc.Client()
|
|
||||||
|
|
||||||
rec := cloneRequest(t, s, "rb-src", `{"new_name":"rb-clone"}`)
|
|
||||||
if rec.Code != http.StatusConflict {
|
|
||||||
t.Fatalf("status = %d, want 409; body=%s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// После rollback Package с именем "rb-clone-*" не должен остаться
|
|
||||||
ctx := context.Background()
|
|
||||||
pkgs, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list packages: %v", err)
|
|
||||||
}
|
|
||||||
for _, p := range pkgs.Items {
|
|
||||||
if strings.HasPrefix(p.GetName(), "rb-clone-") {
|
|
||||||
t.Fatalf("rollback failed: package %q still exists", p.GetName())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Тест: downloadPackageBytes — type:literal ────────────────────────────────
|
|
||||||
|
|
||||||
func TestDownloadPackageBytes_Literal(t *testing.T) {
|
|
||||||
s, _ := newCloneTestServer(t, nil)
|
|
||||||
|
|
||||||
original := []byte("hello from literal")
|
|
||||||
pkg := packageWithLiteral("fission-test", "lit-pkg", "python", original)
|
|
||||||
|
|
||||||
got, err := s.downloadPackageBytes(context.Background(), pkg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("downloadPackageBytes error: %v", err)
|
|
||||||
}
|
|
||||||
if !bytes.Equal(got, original) {
|
|
||||||
t.Fatalf("got %q, want %q", got, original)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Тест: downloadPackageBytes — type:url ────────────────────────────────────
|
|
||||||
|
|
||||||
func TestDownloadPackageBytes_URL(t *testing.T) {
|
|
||||||
const content = "archive-content-from-storagesvc"
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
fmt.Fprint(w, content)
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
pkg := packageWithURL("fission-test", "url-pkg", "python", srv.URL+"/v1/archive?id=abc")
|
|
||||||
s := &Server{http: srv.Client()}
|
|
||||||
|
|
||||||
got, err := s.downloadPackageBytes(context.Background(), pkg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("downloadPackageBytes error: %v", err)
|
|
||||||
}
|
|
||||||
if string(got) != content {
|
|
||||||
t.Fatalf("got %q, want %q", got, content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Тест: downloadPackageBytes — пустой Package → ошибка ────────────────────
|
|
||||||
|
|
||||||
func TestDownloadPackageBytes_EmptyPackage_Error(t *testing.T) {
|
|
||||||
s, _ := newCloneTestServer(t, nil)
|
|
||||||
emptyPkg := &unstructured.Unstructured{Object: map[string]any{
|
|
||||||
"apiVersion": "fission.io/v1",
|
|
||||||
"kind": "Package",
|
|
||||||
"metadata": map[string]any{"name": "empty", "namespace": "fission-test"},
|
|
||||||
"spec": map[string]any{},
|
|
||||||
}}
|
|
||||||
_, err := s.downloadPackageBytes(context.Background(), emptyPkg)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for empty package, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Тест: downloadFromStoragesvc — non-200 → ошибка ────────────────────────
|
|
||||||
|
|
||||||
func TestDownloadFromStoragesvc_Non200(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
s := &Server{http: srv.Client()}
|
|
||||||
_, err := s.downloadFromStoragesvc(context.Background(), srv.URL+"/v1/archive?id=gone")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for 404 response, got nil")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "404") {
|
|
||||||
t.Fatalf("error should mention 404, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Тест: getTriggerMethods — триггер существует ────────────────────────────
|
|
||||||
|
|
||||||
func TestGetTriggerMethods_Found(t *testing.T) {
|
|
||||||
const ns = "fission-test"
|
|
||||||
trig := httpTriggerObject(ns, "my-trig", "my-fn", "/my", []any{"GET", "POST"})
|
|
||||||
s, _ := newCloneTestServer(t, nil, trig)
|
|
||||||
|
|
||||||
methods := s.getTriggerMethods(context.Background(), ns, "my-fn")
|
|
||||||
if len(methods) != 2 {
|
|
||||||
t.Fatalf("methods = %v, want 2 elements", methods)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Тест: getTriggerMethods — триггера нет → nil ────────────────────────────
|
|
||||||
|
|
||||||
func TestGetTriggerMethods_NotFound(t *testing.T) {
|
|
||||||
s, _ := newCloneTestServer(t, nil)
|
|
||||||
methods := s.getTriggerMethods(context.Background(), "fission-test", "no-such-fn")
|
|
||||||
if methods != nil {
|
|
||||||
t.Fatalf("expected nil, got %v", methods)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Тест: невалидный JSON body → 400 ────────────────────────────────────────
|
|
||||||
|
|
||||||
func TestHandleCloneFunction_InvalidJSON(t *testing.T) {
|
|
||||||
s, _ := newCloneTestServer(t, nil)
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/console/api/functions/src/clone",
|
|
||||||
strings.NewReader(`not-json`))
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
s.handleCloneFunction(rec, req, "src")
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Тест: успешный клон — Package содержит ссылку на Function ───────────────
|
|
||||||
|
|
||||||
func TestHandleCloneFunction_PackageRefsCloneFunction(t *testing.T) {
|
|
||||||
const ns = "fission-test"
|
|
||||||
zipData := makeZip(t, "main.py", "def main():\n return 'ref'\n")
|
|
||||||
pkg := packageWithLiteral(ns, "ref-pkg", "python", zipData)
|
|
||||||
fn := functionWithPkg(ns, "ref-src", "ref-pkg", "python", "main.main")
|
|
||||||
|
|
||||||
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
fmt.Fprint(w, `{"id":"fission/arc-ref"}`)
|
|
||||||
}))
|
|
||||||
defer storagesvc.Close()
|
|
||||||
|
|
||||||
s, _ := newCloneTestServer(t, nil, pkg, fn)
|
|
||||||
s.storagesvcURL = storagesvc.URL
|
|
||||||
s.http = storagesvc.Client()
|
|
||||||
|
|
||||||
rec := cloneRequest(t, s, "ref-src", `{"new_name":"ref-clone"}`)
|
|
||||||
if rec.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
cloneFn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, "ref-clone", metav1.GetOptions{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("clone function not found: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pkgRef, _, _ := unstructured.NestedString(cloneFn.Object, "spec", "package", "packageref", "name")
|
|
||||||
if !strings.HasPrefix(pkgRef, "ref-clone-") {
|
|
||||||
t.Fatalf("clone Function.spec.package.packageref.name = %q, should start with 'ref-clone-'", pkgRef)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Package с этим именем должен существовать
|
|
||||||
_, err = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, pkgRef, metav1.GetOptions{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("clone Package %q not found: %v", pkgRef, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
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:])
|
|
||||||
}
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
// mqtriggers.go — CRUD для MQ-триггеров через K8s Deployment + Secret.
|
|
||||||
//
|
|
||||||
// АРХИТЕКТУРА (2026-05-11):
|
|
||||||
// Вместо Fission MessageQueueTrigger CRD (требует mqtrigger компонент Kafka/NATS)
|
|
||||||
// Console деплоит собственный sqs-consumer Deployment в namespace пользователя.
|
|
||||||
//
|
|
||||||
// При CREATE:
|
|
||||||
// 1. Создаём K8s Secret (sqs-mq-<name>) с SQS credentials
|
|
||||||
// 2. Создаём K8s Deployment (mq-<name>) с образом naeel/sqs-consumer:v1.0
|
|
||||||
// FUNCTION_URL = http://router.fission.svc.cluster.local/<functionName>
|
|
||||||
// Лейблы: app.kubernetes.io/managed-by=fission-console, component=mq-trigger
|
|
||||||
//
|
|
||||||
// При LIST: deployments -n <ns> -l component=mq-trigger
|
|
||||||
// При DELETE: удаляем Deployment + Secret
|
|
||||||
//
|
|
||||||
// ИЗМЕНЕНИЯ:
|
|
||||||
// v1 — использовал Fission MQ CRD (компонент отсутствует в кластере)
|
|
||||||
// v2 — K8s Deployment + наш sqs-consumer образ
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"fission-console/internal/model"
|
|
||||||
|
|
||||||
appsv1 "k8s.io/api/apps/v1"
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
||||||
"k8s.io/apimachinery/pkg/api/resource"
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
sqsConsumerImage = "naeel/sqs-consumer:v1.0"
|
|
||||||
sqsDefaultEndpoint = "http://shared-sqs.shared-sqs.svc.cluster.local:4100"
|
|
||||||
fissionRouterBase = "http://router.fission.svc.cluster.local"
|
|
||||||
mqTriggerLabelKey = "component"
|
|
||||||
mqTriggerLabelVal = "mq-trigger"
|
|
||||||
mqManagedByLabel = "app.kubernetes.io/managed-by"
|
|
||||||
mqManagedByVal = "fission-console"
|
|
||||||
)
|
|
||||||
|
|
||||||
func mqSecretName(name string) string { return "sqs-mq-" + name }
|
|
||||||
func mqDeployName(name string) string { return "mq-" + name }
|
|
||||||
|
|
||||||
// ── HTTP хендлеры ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *Server) handleMQTriggersRoot(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case http.MethodGet:
|
|
||||||
s.handleListMQTriggers(w, r)
|
|
||||||
case http.MethodPost:
|
|
||||||
s.handleCreateMQTrigger(w, r)
|
|
||||||
default:
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleMQTriggersAction(w http.ResponseWriter, r *http.Request) {
|
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/console/api/mqtriggers/")
|
|
||||||
name := strings.Trim(path, "/")
|
|
||||||
if name == "" || strings.Contains(name, "/") {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch r.Method {
|
|
||||||
case http.MethodDelete:
|
|
||||||
s.handleDeleteMQTrigger(w, r, name)
|
|
||||||
default:
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── LIST ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *Server) handleListMQTriggers(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ns := s.userNS(r)
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
labelSel := fmt.Sprintf("%s=%s,%s=%s", mqManagedByLabel, mqManagedByVal, mqTriggerLabelKey, mqTriggerLabelVal)
|
|
||||||
deployList, err := s.kube.AppsV1().Deployments(ns).List(ctx, metav1.ListOptions{LabelSelector: labelSel})
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("list mq deployments: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]map[string]any, 0, len(deployList.Items))
|
|
||||||
for i := range deployList.Items {
|
|
||||||
items = append(items, mqDeployToResponse(&deployList.Items[i]))
|
|
||||||
}
|
|
||||||
writeAnyJSON(w, http.StatusOK, map[string]any{"items": items})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CREATE ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *Server) handleCreateMQTrigger(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req model.CreateMQTriggerRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, "decode request: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := validateMQRequest(req); err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ns := s.userNS(r)
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
endpoint := strings.TrimSpace(req.SqsEndpoint)
|
|
||||||
if endpoint == "" {
|
|
||||||
endpoint = sqsDefaultEndpoint
|
|
||||||
}
|
|
||||||
functionURL := fissionRouterBase + "/" + req.FunctionName
|
|
||||||
secretName := mqSecretName(req.Name)
|
|
||||||
deployName := mqDeployName(req.Name)
|
|
||||||
|
|
||||||
// 1. Secret с SQS credentials
|
|
||||||
secret := &corev1.Secret{
|
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
|
||||||
Name: secretName,
|
|
||||||
Namespace: ns,
|
|
||||||
Labels: map[string]string{
|
|
||||||
mqManagedByLabel: mqManagedByVal,
|
|
||||||
mqTriggerLabelKey: mqTriggerLabelVal,
|
|
||||||
"mq-trigger-name": req.Name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
StringData: map[string]string{
|
|
||||||
"SQS_ACCESS_KEY": req.AccessKey,
|
|
||||||
"SQS_SECRET_KEY": req.SecretKey,
|
|
||||||
"SQS_ENDPOINT": endpoint,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if _, err := s.kube.CoreV1().Secrets(ns).Create(ctx, secret, metav1.CreateOptions{}); err != nil {
|
|
||||||
if apierrors.IsAlreadyExists(err) {
|
|
||||||
writeJSONError(w, http.StatusConflict, fmt.Sprintf("mq trigger %q already exists", req.Name))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSONError(w, http.StatusBadGateway, "create secret: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Deployment (sqs-consumer)
|
|
||||||
replicas := int32(1)
|
|
||||||
deploy := &appsv1.Deployment{
|
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
|
||||||
Name: deployName,
|
|
||||||
Namespace: ns,
|
|
||||||
Labels: map[string]string{
|
|
||||||
mqManagedByLabel: mqManagedByVal,
|
|
||||||
mqTriggerLabelKey: mqTriggerLabelVal,
|
|
||||||
"mq-trigger-name": req.Name,
|
|
||||||
},
|
|
||||||
Annotations: map[string]string{
|
|
||||||
"fission-console/mq-trigger-name": req.Name,
|
|
||||||
"fission-console/function": req.FunctionName,
|
|
||||||
"fission-console/queue": req.Queue,
|
|
||||||
"fission-console/sqs-endpoint": endpoint,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Spec: appsv1.DeploymentSpec{
|
|
||||||
Replicas: &replicas,
|
|
||||||
Selector: &metav1.LabelSelector{
|
|
||||||
MatchLabels: map[string]string{"mq-trigger-name": req.Name},
|
|
||||||
},
|
|
||||||
Template: corev1.PodTemplateSpec{
|
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
|
||||||
Labels: map[string]string{
|
|
||||||
"mq-trigger-name": req.Name,
|
|
||||||
mqTriggerLabelKey: mqTriggerLabelVal,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Spec: corev1.PodSpec{
|
|
||||||
Containers: []corev1.Container{{
|
|
||||||
Name: "sqs-consumer",
|
|
||||||
Image: sqsConsumerImage,
|
|
||||||
ImagePullPolicy: corev1.PullAlways,
|
|
||||||
Env: []corev1.EnvVar{
|
|
||||||
{Name: "SQS_QUEUE_NAME", Value: req.Queue},
|
|
||||||
{Name: "SQS_REGION", Value: "us-east-1"},
|
|
||||||
{Name: "FUNCTION_URL", Value: functionURL},
|
|
||||||
{Name: "POLL_INTERVAL", Value: "5"},
|
|
||||||
{Name: "MAX_MESSAGES", Value: "1"},
|
|
||||||
{Name: "MAX_RETRIES", Value: "3"},
|
|
||||||
},
|
|
||||||
EnvFrom: []corev1.EnvFromSource{{
|
|
||||||
SecretRef: &corev1.SecretEnvSource{
|
|
||||||
LocalObjectReference: corev1.LocalObjectReference{Name: secretName},
|
|
||||||
},
|
|
||||||
}},
|
|
||||||
Resources: corev1.ResourceRequirements{
|
|
||||||
Limits: corev1.ResourceList{
|
|
||||||
corev1.ResourceCPU: resource.MustParse("50m"),
|
|
||||||
corev1.ResourceMemory: resource.MustParse("32Mi"),
|
|
||||||
},
|
|
||||||
Requests: corev1.ResourceList{
|
|
||||||
corev1.ResourceCPU: resource.MustParse("10m"),
|
|
||||||
corev1.ResourceMemory: resource.MustParse("16Mi"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
created, err := s.kube.AppsV1().Deployments(ns).Create(ctx, deploy, metav1.CreateOptions{})
|
|
||||||
if err != nil {
|
|
||||||
_ = s.kube.CoreV1().Secrets(ns).Delete(ctx, secretName, metav1.DeleteOptions{})
|
|
||||||
writeJSONError(w, http.StatusBadGateway, "create deployment: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeAnyJSON(w, http.StatusCreated, mqDeployToResponse(created))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DELETE ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *Server) handleDeleteMQTrigger(w http.ResponseWriter, r *http.Request, name string) {
|
|
||||||
ns := s.userNS(r)
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
dErr := s.kube.AppsV1().Deployments(ns).Delete(ctx, mqDeployName(name), metav1.DeleteOptions{})
|
|
||||||
sErr := s.kube.CoreV1().Secrets(ns).Delete(ctx, mqSecretName(name), metav1.DeleteOptions{})
|
|
||||||
|
|
||||||
if dErr != nil && !apierrors.IsNotFound(dErr) {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, "delete deployment: "+dErr.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if sErr != nil && !apierrors.IsNotFound(sErr) {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, "delete secret: "+sErr.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Вспомогательные ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func validateMQRequest(req model.CreateMQTriggerRequest) error {
|
|
||||||
if strings.TrimSpace(req.Name) == "" {
|
|
||||||
return fmt.Errorf("name is required")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(req.FunctionName) == "" {
|
|
||||||
return fmt.Errorf("functionName is required")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(req.Queue) == "" {
|
|
||||||
return fmt.Errorf("queue is required")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(req.AccessKey) == "" {
|
|
||||||
return fmt.Errorf("accessKey is required")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(req.SecretKey) == "" {
|
|
||||||
return fmt.Errorf("secretKey is required")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mqDeployToResponse(d *appsv1.Deployment) map[string]any {
|
|
||||||
ann := d.Annotations
|
|
||||||
if ann == nil {
|
|
||||||
ann = map[string]string{}
|
|
||||||
}
|
|
||||||
triggerName := ann["fission-console/mq-trigger-name"]
|
|
||||||
if triggerName == "" {
|
|
||||||
triggerName = strings.TrimPrefix(d.Name, "mq-")
|
|
||||||
}
|
|
||||||
return map[string]any{
|
|
||||||
"name": triggerName,
|
|
||||||
"deployName": d.Name,
|
|
||||||
"functionName": ann["fission-console/function"],
|
|
||||||
"queue": ann["fission-console/queue"],
|
|
||||||
"sqsEndpoint": ann["fission-console/sqs-endpoint"],
|
|
||||||
"ready": d.Status.ReadyReplicas > 0,
|
|
||||||
"replicas": d.Status.ReadyReplicas,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
// Package api — определение статуса функции.
|
|
||||||
//
|
|
||||||
// Этот файл содержит функции для определения статуса функции на основе:
|
|
||||||
// - buildStatus пакета (Package CRD)
|
|
||||||
// - состояния pod-а (kubernetes Pod)
|
|
||||||
//
|
|
||||||
// Возможные статусы:
|
|
||||||
// - "Ready" (зелёный) — Pod в состоянии Running
|
|
||||||
// - "Cold" (синий) — функция готова, но нет активного Pod
|
|
||||||
// - "Building" (жёлтый) — Package ещё собирается (buildStatus != "succeeded")
|
|
||||||
// - "Error" (красный) — Pod в состоянии ошибки (CrashLoopBackOff, ImagePullBackOff и т.д.) или build failed
|
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"fission-console/internal/fission"
|
|
||||||
|
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FunctionStatus — расширенный объект Function с полем status.
|
|
||||||
type FunctionStatus struct {
|
|
||||||
// Встраиваем исходный объект и добавляем status
|
|
||||||
Object map[string]any `json:"object,inline"`
|
|
||||||
Status string `json:"status"` // "Ready", "Cold", "Building", "Error"
|
|
||||||
}
|
|
||||||
|
|
||||||
// getFunctionStatus определяет статус функции на основе pod-ов и Package.
|
|
||||||
func (s *Server) getFunctionStatus(ctx context.Context, ns string, fn *unstructured.Unstructured) (string, error) {
|
|
||||||
fnName := fn.GetName()
|
|
||||||
|
|
||||||
// Шаг 1: проверяем buildStatus пакета (если есть)
|
|
||||||
packageName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
|
|
||||||
if packageName != "" {
|
|
||||||
pkg, pkgErr := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, packageName, metav1.GetOptions{})
|
|
||||||
if pkgErr == nil {
|
|
||||||
buildStatus, _, _ := unstructured.NestedString(pkg.Object, "status", "buildstatus")
|
|
||||||
// "failed" → Error
|
|
||||||
// "none", "succeeded" → OK, Package готов
|
|
||||||
// "pending", "building" и т.д. → Building
|
|
||||||
if buildStatus == "failed" {
|
|
||||||
return "Error", nil
|
|
||||||
}
|
|
||||||
if buildStatus != "" && buildStatus != "succeeded" && buildStatus != "none" {
|
|
||||||
// Building, Pending и т.д.
|
|
||||||
return "Building", nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Шаг 2: ищем Pod-ы для этой функции (по лейблу functionName=<name>)
|
|
||||||
pods, err := s.kube.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{
|
|
||||||
LabelSelector: fmt.Sprintf("functionName=%s", fnName),
|
|
||||||
})
|
|
||||||
if err != nil && !apierrors.IsNotFound(err) {
|
|
||||||
// Логируем ошибку, но не падаем — возвращаем "Cold" (функция есть, но pod-а нет)
|
|
||||||
return "Cold", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Шаг 3: анализируем состояние pod-ов
|
|
||||||
if pods != nil && len(pods.Items) > 0 {
|
|
||||||
for _, pod := range pods.Items {
|
|
||||||
// Проверяем фазу pod-а
|
|
||||||
phase := pod.Status.Phase
|
|
||||||
if phase == "Running" {
|
|
||||||
// Дополнительная проверка: все контейнеры готовы?
|
|
||||||
allReady := true
|
|
||||||
for _, status := range pod.Status.ContainerStatuses {
|
|
||||||
if !status.Ready {
|
|
||||||
allReady = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if allReady {
|
|
||||||
return "Ready", nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем состояние контейнеров (CrashLoopBackOff, ImagePullBackOff и т.д.)
|
|
||||||
for _, status := range pod.Status.ContainerStatuses {
|
|
||||||
if status.State.Waiting != nil && status.State.Waiting.Reason == "CrashLoopBackOff" {
|
|
||||||
return "Error", nil
|
|
||||||
}
|
|
||||||
if status.State.Waiting != nil && status.State.Waiting.Reason == "ImagePullBackOff" {
|
|
||||||
return "Error", nil
|
|
||||||
}
|
|
||||||
if status.LastTerminationState.Terminated != nil && status.LastTerminationState.Terminated.ExitCode != 0 {
|
|
||||||
return "Error", nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pod есть, но не в Running или Error состоянии → Building/Deploying
|
|
||||||
return "Building", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Нет pod-ов → Cold (функция холодная, но готова)
|
|
||||||
return "Cold", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleListFunctionsWithStatus возвращает список функций с полем "status".
|
|
||||||
// Добавляет status в поле .metadata.annotations["fission-console/status"] каждой функции.
|
|
||||||
func (s *Server) handleListFunctionsWithStatus(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) // 30 сек для определения статуса всех функций
|
|
||||||
defer cancel()
|
|
||||||
ns := s.userNS(r)
|
|
||||||
|
|
||||||
// Получаем список функций
|
|
||||||
list, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("list functions: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обогащаем каждую функцию статусом (добавляем в .metadata.annotations["fission-console/status"])
|
|
||||||
for i := range list.Items {
|
|
||||||
status, _ := s.getFunctionStatus(ctx, ns, &list.Items[i])
|
|
||||||
anns := list.Items[i].GetAnnotations()
|
|
||||||
if anns == nil {
|
|
||||||
anns = make(map[string]string)
|
|
||||||
}
|
|
||||||
anns["fission-console/status"] = status
|
|
||||||
list.Items[i].SetAnnotations(anns)
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, list.Items)
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
// Package billing — слой записи статистики вызовов функций.
|
|
||||||
//
|
|
||||||
// Независим от типа БД: снаружи виден только интерфейс Store.
|
|
||||||
// Если BILLING_DSN не задан — работает NoopStore (тихо, не крашит).
|
|
||||||
package billing
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// TriggerType описывает источник вызова.
|
|
||||||
const (
|
|
||||||
TriggerHTTP = "http" // вызов через /fn/... снаружи
|
|
||||||
TriggerCron = "cron" // вызов по расписанию
|
|
||||||
TriggerConsole = "console" // вызов через кнопку «Вызов» в UI
|
|
||||||
TriggerEvent = "event" // lifecycle: create/delete/clone/update
|
|
||||||
)
|
|
||||||
|
|
||||||
// Invocation — одна запись о вызове или событии функции.
|
|
||||||
type Invocation struct {
|
|
||||||
Namespace string // пользовательский namespace (= пользователь)
|
|
||||||
FunctionName string // имя функции
|
|
||||||
TriggerType string // TriggerHTTP / TriggerCron / TriggerConsole / TriggerEvent
|
|
||||||
Route string // HTTP маршрут (/abc123/my-func), пусто для event
|
|
||||||
HTTPMethod string // GET/POST/... пусто для event
|
|
||||||
StartedAt time.Time // время начала
|
|
||||||
DurationMS int64 // длительность в миллисекундах (0 для event)
|
|
||||||
StatusCode int // HTTP статус ответа (0 для event)
|
|
||||||
ColdStart bool // true = первый вызов после создания/простоя
|
|
||||||
RequestBytes int64 // размер тела запроса
|
|
||||||
ResponseBytes int64 // размер тела ответа
|
|
||||||
ErrorMsg string // сообщение об ошибке, пусто если успех
|
|
||||||
RecordedBy string // "console" | "router"
|
|
||||||
EventType string // для TriggerEvent: "create" | "delete" | "clone" | "update" | "invoke"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store — интерфейс записи статистики.
|
|
||||||
// Реализации: pgStore (PostgreSQL через pgx), NoopStore (заглушка).
|
|
||||||
type Store interface {
|
|
||||||
// RecordInvocation записывает одно событие асинхронно (fire-and-forget).
|
|
||||||
RecordInvocation(inv Invocation)
|
|
||||||
// Close освобождает ресурсы (пул соединений и т.д.).
|
|
||||||
Close()
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package billing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewStore создаёт Store из переменной окружения BILLING_DSN.
|
|
||||||
// Если DSN пустой — возвращает NoopStore (статистика отключена, сервер работает нормально).
|
|
||||||
func NewStore() Store {
|
|
||||||
dsn := os.Getenv("BILLING_DSN")
|
|
||||||
if dsn == "" {
|
|
||||||
log.Printf("billing: BILLING_DSN not set, statistics disabled")
|
|
||||||
return NoopStore{}
|
|
||||||
}
|
|
||||||
store, err := NewPostgresStore(context.Background(), dsn)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("billing: failed to connect to PostgreSQL: %v — statistics disabled", err)
|
|
||||||
return NoopStore{}
|
|
||||||
}
|
|
||||||
return store
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package billing
|
|
||||||
|
|
||||||
// NoopStore — заглушка Store, которая тихо игнорирует все записи.
|
|
||||||
// Используется когда BILLING_DSN не задан.
|
|
||||||
type NoopStore struct{}
|
|
||||||
|
|
||||||
func (NoopStore) RecordInvocation(_ Invocation) {}
|
|
||||||
func (NoopStore) Close() {}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
package billing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// pgStore — реализация Store поверх PostgreSQL через pgx connection pool.
|
|
||||||
type pgStore struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPostgresStore создаёт Store с пулом соединений к PostgreSQL.
|
|
||||||
// dsn — строка вида postgres://user:pass@host:5432/dbname
|
|
||||||
// При ошибке подключения — возвращает ошибку, вызывающий код решает как реагировать.
|
|
||||||
func NewPostgresStore(ctx context.Context, dsn string) (Store, error) {
|
|
||||||
cfg, err := pgxpool.ParseConfig(dsn)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
cfg.MaxConns = 4
|
|
||||||
cfg.MinConns = 1
|
|
||||||
cfg.MaxConnIdleTime = 5 * time.Minute
|
|
||||||
|
|
||||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// Проверяем живость соединения
|
|
||||||
if err := pool.Ping(ctx); err != nil {
|
|
||||||
pool.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
log.Printf("billing: connected to PostgreSQL")
|
|
||||||
return &pgStore{pool: pool}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordInvocation вставляет запись асинхронно — не блокирует основной поток.
|
|
||||||
func (s *pgStore) RecordInvocation(inv Invocation) {
|
|
||||||
go func() {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
INSERT INTO invocations (
|
|
||||||
namespace, function_name, trigger_type, route, http_method,
|
|
||||||
started_at, duration_ms, status_code, cold_start,
|
|
||||||
request_bytes, response_bytes, error_msg, recorded_by, event_type
|
|
||||||
) VALUES (
|
|
||||||
$1,$2,$3,$4,$5,
|
|
||||||
$6,$7,$8,$9,
|
|
||||||
$10,$11,$12,$13,$14
|
|
||||||
)`,
|
|
||||||
inv.Namespace,
|
|
||||||
inv.FunctionName,
|
|
||||||
inv.TriggerType,
|
|
||||||
inv.Route,
|
|
||||||
inv.HTTPMethod,
|
|
||||||
inv.StartedAt,
|
|
||||||
inv.DurationMS,
|
|
||||||
inv.StatusCode,
|
|
||||||
inv.ColdStart,
|
|
||||||
inv.RequestBytes,
|
|
||||||
inv.ResponseBytes,
|
|
||||||
nullIfEmpty(inv.ErrorMsg),
|
|
||||||
inv.RecordedBy,
|
|
||||||
nullIfEmpty(inv.EventType),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("billing: insert invocation: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close закрывает пул соединений.
|
|
||||||
func (s *pgStore) Close() {
|
|
||||||
s.pool.Close()
|
|
||||||
log.Printf("billing: connection pool closed")
|
|
||||||
}
|
|
||||||
|
|
||||||
// nullIfEmpty возвращает nil для пустой строки (SQL NULL вместо пустой строки).
|
|
||||||
func nullIfEmpty(s string) interface{} {
|
|
||||||
if s == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
package runtime
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/zip"
|
|
||||||
"bytes"
|
|
||||||
)
|
|
||||||
|
|
||||||
// buildZip создаёт zip-архив с одним файлом fileName и содержимым content.
|
|
||||||
// Вспомогательная функция: используется в buildScriptZip (PHP/Ruby),
|
|
||||||
// а также как основа для buildGoSourceZip и buildJSDeployZip.
|
|
||||||
func buildZip(fileName string, content []byte) ([]byte, error) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
zw := zip.NewWriter(&buf)
|
|
||||||
|
|
||||||
fw, err := zw.Create(fileName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if _, err := fw.Write(content); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := zw.Close(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return buf.Bytes(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package runtime
|
|
||||||
|
|
||||||
// BuildScriptZip создаёт zip-архив с одним файлом fileName и содержимым code.
|
|
||||||
// Используется для PHP и Ruby — языков где среда Fission ожидает
|
|
||||||
// именованный файл (handler.rb, handler.pl, main.php и т.д.) внутри архива.
|
|
||||||
//
|
|
||||||
// Почему zip, а не просто literal:
|
|
||||||
// Fission poolmgr при специализации пода распаковывает deployment archive,
|
|
||||||
// находит нужный файл по имени и загружает его в среду выполнения.
|
|
||||||
// Если передать просто байты кода в deployment.literal — среда не знает расширение.
|
|
||||||
func BuildScriptZip(code, fileName string) ([]byte, error) {
|
|
||||||
return buildZip(fileName, []byte(code))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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))
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,382 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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 "" }
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
// 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,'"');
|
|
||||||
}
|
|
||||||
@@ -12,8 +12,6 @@ import (
|
|||||||
|
|
||||||
"fission-console/internal/api"
|
"fission-console/internal/api"
|
||||||
"fission-console/internal/auth"
|
"fission-console/internal/auth"
|
||||||
"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,9 +48,6 @@ func main() {
|
|||||||
Demo: &auth.DemoAuthenticator{},
|
Demo: &auth.DemoAuthenticator{},
|
||||||
}
|
}
|
||||||
|
|
||||||
billingStore := billing.NewStore()
|
|
||||||
statsProvider := stats.NewProvider()
|
|
||||||
|
|
||||||
srv := api.NewServer(api.Config{
|
srv := api.NewServer(api.Config{
|
||||||
Dyn: dyn,
|
Dyn: dyn,
|
||||||
Kube: kube,
|
Kube: kube,
|
||||||
@@ -69,8 +64,6 @@ func main() {
|
|||||||
LLMUrl: envDefault("FISSION_LLM_URL", "https://api.aillm.ru"),
|
LLMUrl: envDefault("FISSION_LLM_URL", "https://api.aillm.ru"),
|
||||||
LLMKey: os.Getenv("FISSION_LLM_KEY"),
|
LLMKey: os.Getenv("FISSION_LLM_KEY"),
|
||||||
// --- end ai/ask feature ---
|
// --- end ai/ask feature ---
|
||||||
Billing: billingStore,
|
|
||||||
Stats: statsProvider,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Запускаем фоновые горутины: reaper истёкших функций
|
// Запускаем фоновые горутины: reaper истёкших функций
|
||||||
@@ -15,12 +15,9 @@ 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", "create", "update", "patch", "delete"]
|
verbs: ["get", "list", "update", "patch"]
|
||||||
- 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"]
|
||||||
@@ -58,7 +55,7 @@ spec:
|
|||||||
serviceAccountName: fission-console
|
serviceAccountName: fission-console
|
||||||
containers:
|
containers:
|
||||||
- name: console
|
- name: console
|
||||||
image: naeel/fission-console:v1.3.96
|
image: naeel/fission-console:v1.3.69
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8090
|
- containerPort: 8090
|
||||||
@@ -85,16 +82,6 @@ spec:
|
|||||||
key: password
|
key: password
|
||||||
- name: FISSION_STORAGESVC_URL
|
- name: FISSION_STORAGESVC_URL
|
||||||
value: "http://storagesvc.fission.svc.cluster.local"
|
value: "http://storagesvc.fission.svc.cluster.local"
|
||||||
- name: BILLING_DSN
|
|
||||||
value: "postgres://super:BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif@postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local:5432/sqsdb"
|
|
||||||
- name: GRAFANA_INTERNAL_URL
|
|
||||||
value: "http://grafana.grafana.svc.cluster.local:3000"
|
|
||||||
- name: GRAFANA_PUBLIC_URL
|
|
||||||
value: "https://fission.kube5s.ru/grafana"
|
|
||||||
- name: GRAFANA_ADMIN_USER
|
|
||||||
value: "admin"
|
|
||||||
- name: GRAFANA_ADMIN_PASS
|
|
||||||
value: "GrafanaAdmin2026!"
|
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /health
|
path: /health
|
||||||
@@ -20,10 +20,6 @@ require (
|
|||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/google/gnostic-models v0.7.0 // indirect
|
github.com/google/gnostic-models v0.7.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
|
||||||
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/mailru/easyjson v0.7.7 // indirect
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
@@ -36,7 +32,6 @@ require (
|
|||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/oauth2 v0.30.0 // indirect
|
golang.org/x/oauth2 v0.30.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/term v0.37.0 // indirect
|
golang.org/x/term v0.37.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/text v0.31.0 // indirect
|
||||||
@@ -29,14 +29,6 @@ github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgY
|
|||||||
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
|
||||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
|
||||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
@@ -78,7 +70,6 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
|
|||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
@@ -108,8 +99,6 @@ golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKl
|
|||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -255,8 +255,6 @@ func buildChatSystemPrompt() string {
|
|||||||
- **Cron** — можно включить/выключить и изменить расписание
|
- **Cron** — можно включить/выключить и изменить расписание
|
||||||
- **Код** — редактируется (для функций созданных из кода)
|
- **Код** — редактируется (для функций созданных из кода)
|
||||||
- **Заменить архив** — загрузить новый zip (для функций из архива)
|
- **Заменить архив** — загрузить новый zip (для функций из архива)
|
||||||
- **Переменные окружения** — раздел в конце редактора. Кнопка "+ Добавить" добавляет переменную (имя + значение). Кнопка "Сохранить env vars" применяет изменения. Переменные сразу доступны в поде функции через os.environ (Python), process.env (Node.js), os.Getenv (Go) и т.д.
|
|
||||||
- **📋 Клон** — кнопка в нижней панели. Позволяет создать копию функции с новым именем. Клон наследует код, язык, entrypoint, timeout. Маршрут назначается автоматически.
|
|
||||||
|
|
||||||
## Вызов функции (кнопка Вызов)
|
## Вызов функции (кнопка Вызов)
|
||||||
Открывается модальное окно. Можно указать тело запроса (JSON) и нажать "Вызвать".
|
Открывается модальное окно. Можно указать тело запроса (JSON) и нажать "Вызвать".
|
||||||
@@ -320,7 +318,12 @@ Ruby (entrypoint: handler):
|
|||||||
- ` + "`@hourly`" + `, ` + "`@daily`" + `, ` + "`@weekly`" + ` — стандартные псевдонимы
|
- ` + "`@hourly`" + `, ` + "`@daily`" + `, ` + "`@weekly`" + ` — стандартные псевдонимы
|
||||||
|
|
||||||
## ESC и закрытие окон
|
## ESC и закрытие окон
|
||||||
Нажатие ESC закрывает любое открытое окно (модалку).`)
|
Нажатие ESC закрывает любое открытое окно (модалку).
|
||||||
|
|
||||||
|
## Чем НЕ занимаешься
|
||||||
|
- Не рассказываешь про устройство платформы изнутри
|
||||||
|
- Не помогаешь с инфраструктурой, деплоем, kubernetes
|
||||||
|
- Не отвечаешь на вопросы не связанные с работой в консоли`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func prettyLanguageName(lang string) string {
|
func prettyLanguageName(lang string) string {
|
||||||
+6
-34
@@ -22,7 +22,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fission-console/internal/billing"
|
|
||||||
"fission-console/internal/fission"
|
"fission-console/internal/fission"
|
||||||
"fission-console/internal/model"
|
"fission-console/internal/model"
|
||||||
"fission-console/internal/runtime"
|
"fission-console/internal/runtime"
|
||||||
@@ -35,26 +34,17 @@ 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 кода (или zip если есть deps).
|
// Для остальных (python) — raw bytes кода.
|
||||||
// deps — содержимое файла зависимостей (requirements.txt, Gemfile, composer.json).
|
func buildDeployArchive(lang, code string) ([]byte, error) {
|
||||||
// Если 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: // python
|
default:
|
||||||
return runtime.BuildPythonZip(code, deps)
|
return []byte(code), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +172,7 @@ func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
|
|||||||
"buildcommand": "build",
|
"buildcommand": "build",
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
deployBytes, archiveErr := buildDeployArchive(req.Language, req.Code, req.Deps)
|
deployBytes, archiveErr := buildDeployArchive(req.Language, req.Code)
|
||||||
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
|
||||||
@@ -214,9 +204,6 @@ 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 {
|
||||||
@@ -288,16 +275,6 @@ func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.billing.RecordInvocation(billing.Invocation{
|
|
||||||
Namespace: ns,
|
|
||||||
FunctionName: req.Name,
|
|
||||||
TriggerType: billing.TriggerEvent,
|
|
||||||
StartedAt: now,
|
|
||||||
StatusCode: http.StatusCreated,
|
|
||||||
RecordedBy: "console",
|
|
||||||
EventType: "create",
|
|
||||||
})
|
|
||||||
|
|
||||||
writeAnyJSON(w, http.StatusCreated, map[string]any{
|
writeAnyJSON(w, http.StatusCreated, map[string]any{
|
||||||
"name": req.Name,
|
"name": req.Name,
|
||||||
"package": pkgName,
|
"package": pkgName,
|
||||||
@@ -341,7 +318,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, req.Deps)
|
deployBytes, archiveErr := buildDeployArchive(lang, req.Code)
|
||||||
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
|
||||||
@@ -405,11 +382,6 @@ 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,
|
||||||
+4
-247
@@ -1,12 +1,10 @@
|
|||||||
// Package api — CRUD операции с функциями: чтение, удаление, обновление таймаута, логи, env vars.
|
// Package api — CRUD операции с функциями: чтение, удаление, обновление таймаута, логи.
|
||||||
//
|
//
|
||||||
// Этот файл содержит операции, не связанные с заменой кода/архива:
|
// Этот файл содержит операции, не связанные с заменой кода/архива:
|
||||||
// - handleGetFunction — GET /functions/:name (детали: код, route, environment, source_type)
|
// - handleGetFunction — GET /functions/:name (детали: код, route, environment, source_type)
|
||||||
// - handleDeleteFunction — DELETE /functions/:name (каскадное удаление: триггеры, Package, S3)
|
// - handleDeleteFunction — DELETE /functions/:name (каскадное удаление: триггеры, Package, S3)
|
||||||
// - handleUpdateFunctionTimeout — PUT /functions/:name/timeout (только таймаут, без замены кода)
|
// - handleUpdateFunctionTimeout — PUT /functions/:name/timeout (только таймаут, без замены кода)
|
||||||
// - handleGetFunctionLogs — GET /functions/:name/logs (логи пода через Kubernetes API)
|
// - handleGetFunctionLogs — GET /functions/:name/logs (логи пода через Kubernetes API)
|
||||||
// - handleGetFunctionEnvVars — GET /functions/:name/envvars (переменные окружения из CRD)
|
|
||||||
// - handlePutFunctionEnvVars — PUT /functions/:name/envvars (обновить env vars в CRD)
|
|
||||||
//
|
//
|
||||||
// Операции с кодом и архивом — в function_code.go и function_archive.go соответственно.
|
// Операции с кодом и архивом — в function_code.go и function_archive.go соответственно.
|
||||||
// Вызов функции — в function_invoke.go.
|
// Вызов функции — в function_invoke.go.
|
||||||
@@ -21,14 +19,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fission-console/internal/billing"
|
|
||||||
"fission-console/internal/fission"
|
"fission-console/internal/fission"
|
||||||
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
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"
|
||||||
k8stypes "k8s.io/apimachinery/pkg/types"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleGetFunction возвращает детали функции: код, environment, route, methods.
|
// handleGetFunction возвращает детали функции: код, environment, route, methods.
|
||||||
@@ -83,7 +79,6 @@ 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
|
||||||
@@ -91,9 +86,6 @@ 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{
|
||||||
@@ -106,12 +98,10 @@ 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,
|
||||||
"methods": methods,
|
"methods": methods,
|
||||||
"env_vars": extractEnvVars(fn),
|
|
||||||
"raw": fn.Object,
|
"raw": fn.Object,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -234,16 +224,6 @@ func (s *Server) handleDeleteFunction(w http.ResponseWriter, r *http.Request, na
|
|||||||
|
|
||||||
// (reconciler NS удалён — за FISSION_RESOURCE_NAMESPACES теперь отвечает Layer 1 NSWatcher)
|
// (reconciler NS удалён — за FISSION_RESOURCE_NAMESPACES теперь отвечает Layer 1 NSWatcher)
|
||||||
|
|
||||||
s.billing.RecordInvocation(billing.Invocation{
|
|
||||||
Namespace: ns,
|
|
||||||
FunctionName: name,
|
|
||||||
TriggerType: billing.TriggerEvent,
|
|
||||||
StartedAt: time.Now(),
|
|
||||||
StatusCode: http.StatusOK,
|
|
||||||
RecordedBy: "console",
|
|
||||||
EventType: "delete",
|
|
||||||
})
|
|
||||||
|
|
||||||
writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name, "package": pkgName})
|
writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name, "package": pkgName})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,226 +284,3 @@ func (s *Server) handleGetFunctionLogs(w http.ResponseWriter, r *http.Request, n
|
|||||||
"logs": allLogs.String(),
|
"logs": allLogs.String(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGetFunctionEnvVars возвращает переменные окружения функции из .spec.runtime.container.env
|
|
||||||
func (s *Server) handleGetFunctionEnvVars(w http.ResponseWriter, r *http.Request, name string) {
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
ns := s.userNS(r)
|
|
||||||
|
|
||||||
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
|
|
||||||
if err != nil {
|
|
||||||
status := http.StatusBadGateway
|
|
||||||
if apierrors.IsNotFound(err) {
|
|
||||||
status = http.StatusNotFound
|
|
||||||
}
|
|
||||||
writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
envVars := extractEnvVars(fn)
|
|
||||||
writeAnyJSON(w, http.StatusOK, map[string]any{"env_vars": envVars})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handlePutFunctionEnvVars обновляет переменные окружения функции.
|
|
||||||
//
|
|
||||||
// Логика переключения ExecutorType:
|
|
||||||
// - Если env vars непустые → ExecutorType: newdeploy + spec.podspec.containers[0].env
|
|
||||||
// (newdeploy создаёт dedicated Deployment, Kubernetes ставит env vars на уровне ОС)
|
|
||||||
// - Если env vars пустые → ExecutorType: poolmgr, podspec удаляется
|
|
||||||
// (poolmgr использует warm pool, быстрый cold start)
|
|
||||||
//
|
|
||||||
// Это единственный универсальный способ передать env vars в pod для всех языков
|
|
||||||
// (Python, Go, Ruby, PHP, Node.js) без изменений в env-серверах.
|
|
||||||
func (s *Server) handlePutFunctionEnvVars(w http.ResponseWriter, r *http.Request, name string) {
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
ns := s.userNS(r)
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
EnvVars []map[string]string `json:"env_vars"` // [{name: "KEY", value: "VAL"}, ...]
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Валидация: имена переменных
|
|
||||||
for _, ev := range req.EnvVars {
|
|
||||||
k := ev["name"]
|
|
||||||
if k == "" {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, "env var name cannot be empty")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
|
|
||||||
if err != nil {
|
|
||||||
status := http.StatusBadGateway
|
|
||||||
if apierrors.IsNotFound(err) {
|
|
||||||
status = http.StatusNotFound
|
|
||||||
}
|
|
||||||
writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сериализуем в JSON для аннотации (для UI)
|
|
||||||
envJSON, err := json.Marshal(req.EnvVars)
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("marshal env vars: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем аннотации
|
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
|
||||||
ann := fn.GetAnnotations()
|
|
||||||
if ann == nil {
|
|
||||||
ann = map[string]string{}
|
|
||||||
}
|
|
||||||
ann[functionUpdatedAtAnnotation] = now
|
|
||||||
ann["fission-console/env-vars"] = string(envJSON)
|
|
||||||
fn.SetAnnotations(ann)
|
|
||||||
|
|
||||||
if len(req.EnvVars) > 0 {
|
|
||||||
// Есть env vars → newdeploy + podspec с env vars
|
|
||||||
envName, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
|
|
||||||
|
|
||||||
// Строим список env vars для Kubernetes
|
|
||||||
envList := make([]any, 0, len(req.EnvVars))
|
|
||||||
for _, ev := range req.EnvVars {
|
|
||||||
envList = append(envList, map[string]any{
|
|
||||||
"name": ev["name"],
|
|
||||||
"value": ev["value"],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Устанавливаем podspec.containers[0] с env vars
|
|
||||||
// Имя контейнера = имя environment (стандарт Fission)
|
|
||||||
if err := unstructured.SetNestedSlice(fn.Object, []any{
|
|
||||||
map[string]any{
|
|
||||||
"name": envName,
|
|
||||||
"env": envList,
|
|
||||||
},
|
|
||||||
}, "spec", "podspec", "containers"); err != nil {
|
|
||||||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set podspec: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Переключаем на newdeploy (только он поддерживает podspec env)
|
|
||||||
if err := unstructured.SetNestedField(fn.Object, map[string]any{
|
|
||||||
"ExecutionStrategy": map[string]any{
|
|
||||||
"ExecutorType": "newdeploy",
|
|
||||||
"MinScale": int64(0),
|
|
||||||
"MaxScale": int64(1),
|
|
||||||
"SpecializationTimeout": int64(120),
|
|
||||||
},
|
|
||||||
"StrategyType": "execution",
|
|
||||||
}, "spec", "InvokeStrategy"); err != nil {
|
|
||||||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set invoke strategy: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Нет env vars → poolmgr, убираем podspec
|
|
||||||
unstructured.RemoveNestedField(fn.Object, "spec", "podspec")
|
|
||||||
|
|
||||||
if err := unstructured.SetNestedField(fn.Object, map[string]any{
|
|
||||||
"ExecutionStrategy": map[string]any{
|
|
||||||
"ExecutorType": "poolmgr",
|
|
||||||
"SpecializationTimeout": int64(120),
|
|
||||||
},
|
|
||||||
"StrategyType": "execution",
|
|
||||||
}, "spec", "InvokeStrategy"); err != nil {
|
|
||||||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set invoke strategy: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Update(ctx, fn, metav1.UpdateOptions{}); err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update function %q: %v", name, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fission newdeploy НЕ применяет fn.Spec.PodSpec при построении Deployment.
|
|
||||||
// Поэтому напрямую патчим существующий Deployment через Kubernetes API.
|
|
||||||
if len(req.EnvVars) > 0 && s.kube != nil {
|
|
||||||
envName, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
|
|
||||||
if envName != "" {
|
|
||||||
if err := s.patchDeploymentEnvVars(ctx, ns, name, envName, req.EnvVars); err != nil {
|
|
||||||
// Не фатальная ошибка — CRD обновлён, Deployment будет обновлён позже
|
|
||||||
_ = err // warn only
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
executor := "poolmgr"
|
|
||||||
if len(req.EnvVars) > 0 {
|
|
||||||
executor = "newdeploy"
|
|
||||||
}
|
|
||||||
writeAnyJSON(w, http.StatusOK, map[string]any{"updated": true, "count": len(req.EnvVars), "executor": executor})
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractEnvVars читает аннотацию fission-console/env-vars (JSON) из Function CRD
|
|
||||||
// Возвращает [{name, value}, ...]
|
|
||||||
func extractEnvVars(fn *unstructured.Unstructured) []map[string]string {
|
|
||||||
ann := fn.GetAnnotations()
|
|
||||||
if ann == nil {
|
|
||||||
return []map[string]string{}
|
|
||||||
}
|
|
||||||
raw := ann["fission-console/env-vars"]
|
|
||||||
if raw == "" {
|
|
||||||
return []map[string]string{}
|
|
||||||
}
|
|
||||||
var result []map[string]string
|
|
||||||
if err := json.Unmarshal([]byte(raw), &result); err != nil {
|
|
||||||
return []map[string]string{}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// patchDeploymentEnvVars находит Deployment newdeploy для функции и патчит его env vars.
|
|
||||||
// Fission не применяет fn.Spec.PodSpec при построении Deployment, поэтому патчим напрямую.
|
|
||||||
// Поиск по labels: functionName=<name>, functionNamespace=<ns>, executorType=newdeploy
|
|
||||||
func (s *Server) patchDeploymentEnvVars(ctx context.Context, ns, fnName, envContainerName string, envVars []map[string]string) error {
|
|
||||||
selector := fmt.Sprintf("functionName=%s,functionNamespace=%s,executorType=newdeploy", fnName, ns)
|
|
||||||
deplList, err := s.kube.AppsV1().Deployments(ns).List(ctx, metav1.ListOptions{LabelSelector: selector})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("list deployments: %w", err)
|
|
||||||
}
|
|
||||||
if len(deplList.Items) == 0 {
|
|
||||||
return nil // Deployment ещё не создан Fission — ничего страшного
|
|
||||||
}
|
|
||||||
|
|
||||||
// Строим env vars для patch (StrategicMergePatch мержит по "name")
|
|
||||||
envItems := make([]map[string]string, 0, len(envVars))
|
|
||||||
for _, ev := range envVars {
|
|
||||||
envItems = append(envItems, map[string]string{"name": ev["name"], "value": ev["value"]})
|
|
||||||
}
|
|
||||||
|
|
||||||
patch := map[string]any{
|
|
||||||
"spec": map[string]any{
|
|
||||||
"template": map[string]any{
|
|
||||||
"spec": map[string]any{
|
|
||||||
"containers": []any{
|
|
||||||
map[string]any{
|
|
||||||
"name": envContainerName,
|
|
||||||
"env": envItems,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
patchBytes, err := json.Marshal(patch)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshal patch: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, depl := range deplList.Items {
|
|
||||||
if _, err := s.kube.AppsV1().Deployments(ns).Patch(
|
|
||||||
ctx, depl.Name, k8stypes.StrategicMergePatchType, patchBytes, metav1.PatchOptions{},
|
|
||||||
); err != nil {
|
|
||||||
return fmt.Errorf("patch deployment %s: %w", depl.Name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
+2
-37
@@ -22,7 +22,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fission-console/internal/billing"
|
|
||||||
"fission-console/internal/fission"
|
"fission-console/internal/fission"
|
||||||
|
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
@@ -203,26 +202,9 @@ func (s *Server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, na
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
durationMS := time.Since(start).Milliseconds()
|
|
||||||
s.billing.RecordInvocation(billing.Invocation{
|
|
||||||
Namespace: ns,
|
|
||||||
FunctionName: name,
|
|
||||||
TriggerType: billing.TriggerConsole,
|
|
||||||
Route: invokeURL,
|
|
||||||
HTTPMethod: invokeMethod,
|
|
||||||
StartedAt: start,
|
|
||||||
DurationMS: durationMS,
|
|
||||||
StatusCode: resp.StatusCode,
|
|
||||||
RequestBytes: int64(len(bodyBytes)),
|
|
||||||
ResponseBytes: int64(len(respBody)),
|
|
||||||
RecordedBy: "console",
|
|
||||||
EventType: "invoke",
|
|
||||||
})
|
|
||||||
|
|
||||||
writeAnyJSON(w, http.StatusOK, map[string]any{
|
writeAnyJSON(w, http.StatusOK, map[string]any{
|
||||||
"status": resp.StatusCode,
|
"status": resp.StatusCode,
|
||||||
"latency_ms": durationMS,
|
"latency_ms": time.Since(start).Milliseconds(),
|
||||||
"response_raw": string(respBody),
|
"response_raw": string(respBody),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -329,26 +311,9 @@ func (s *Server) invokeInternalFunction(w http.ResponseWriter, r *http.Request,
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
durationMS := time.Since(start).Milliseconds()
|
|
||||||
s.billing.RecordInvocation(billing.Invocation{
|
|
||||||
Namespace: namespace,
|
|
||||||
FunctionName: functionName,
|
|
||||||
TriggerType: billing.TriggerHTTP,
|
|
||||||
Route: extraPath,
|
|
||||||
HTTPMethod: r.Method,
|
|
||||||
StartedAt: start,
|
|
||||||
DurationMS: durationMS,
|
|
||||||
StatusCode: resp.StatusCode,
|
|
||||||
RequestBytes: int64(len(bodyBytes)),
|
|
||||||
ResponseBytes: int64(len(respBody)),
|
|
||||||
RecordedBy: "console",
|
|
||||||
EventType: "invoke",
|
|
||||||
})
|
|
||||||
|
|
||||||
writeAnyJSON(w, http.StatusOK, map[string]any{
|
writeAnyJSON(w, http.StatusOK, map[string]any{
|
||||||
"status": resp.StatusCode,
|
"status": resp.StatusCode,
|
||||||
"latency_ms": durationMS,
|
"latency_ms": time.Since(start).Milliseconds(),
|
||||||
"response_raw": string(respBody),
|
"response_raw": string(respBody),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fission-console/internal/auth"
|
"fission-console/internal/auth"
|
||||||
|
"fission-console/internal/fission"
|
||||||
)
|
)
|
||||||
|
|
||||||
// validFuncName — RFC 1123 subdomain label: строчные буквы+цифры+дефис, без дефиса в начале/конце.
|
// validFuncName — RFC 1123 subdomain label: строчные буквы+цифры+дефис, без дефиса в начале/конце.
|
||||||
@@ -50,11 +51,11 @@ const fissionSourceTypeAnnotation = "fission-console/source-type"
|
|||||||
const defaultFunctionInvokeTimeout = 60 * time.Second
|
const defaultFunctionInvokeTimeout = 60 * time.Second
|
||||||
|
|
||||||
// handleFunctionsRoot обрабатывает запросы к /console/api/functions без имени функции.
|
// handleFunctionsRoot обрабатывает запросы к /console/api/functions без имени функции.
|
||||||
// GET → список всех функций со статусами, POST → создать новую.
|
// GET → список всех функций, POST → создать новую.
|
||||||
func (s *Server) handleFunctionsRoot(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleFunctionsRoot(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
s.handleListFunctionsWithStatus(w, r)
|
s.handleList(fission.FunctionGVR)(w, r)
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
s.handleCreateFunction(w, r)
|
s.handleCreateFunction(w, r)
|
||||||
default:
|
default:
|
||||||
@@ -126,25 +127,6 @@ func (s *Server) handleFunctionsAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// /functions/:name/envvars — переменные окружения
|
|
||||||
if len(parts) == 2 && parts[1] == "envvars" {
|
|
||||||
switch r.Method {
|
|
||||||
case http.MethodGet:
|
|
||||||
s.handleGetFunctionEnvVars(w, r, name)
|
|
||||||
case http.MethodPut:
|
|
||||||
s.handlePutFunctionEnvVars(w, r, name)
|
|
||||||
default:
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// /functions/:name/clone — клонировать функцию с новым именем
|
|
||||||
if len(parts) == 2 && parts[1] == "clone" && r.Method == http.MethodPost {
|
|
||||||
s.handleCloneFunction(w, r, name)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,13 +163,6 @@ 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})
|
||||||
}
|
}
|
||||||
@@ -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', (?:'require', )?(.+?)\)\)\(__mod, __mod\.exports(?:, require)?\);\s*const _fn = __mod\.exports;`)
|
var nodeJSWrapperPattern = regexp.MustCompile(`(?s)^const __mod = \{ exports: \{\} \};\s*\(new Function\('module', 'exports', (.+?)\)\)\(__mod, __mod\.exports\);\s*const _fn = __mod\.exports;`)
|
||||||
|
|
||||||
func decodeNodeJSWrapperSource(decoded []byte) (string, bool) {
|
func decodeNodeJSWrapperSource(decoded []byte) (string, bool) {
|
||||||
text := string(decoded)
|
text := string(decoded)
|
||||||
@@ -13,10 +13,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fission-console/internal/auth"
|
"fission-console/internal/auth"
|
||||||
"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"
|
||||||
@@ -61,12 +59,6 @@ type Server struct {
|
|||||||
|
|
||||||
// nsManager управляет жизненным циклом пользовательских namespace-ов.
|
// nsManager управляет жизненным циклом пользовательских namespace-ов.
|
||||||
nsManager *cloud.NSManager
|
nsManager *cloud.NSManager
|
||||||
|
|
||||||
// billing — слой записи статистики вызовов. NoopStore если BILLING_DSN не задан.
|
|
||||||
billing billing.Store
|
|
||||||
|
|
||||||
// stats — аналитический слой (Grafana Organizations). NoopProvider если не настроен.
|
|
||||||
stats stats.StatsProvider
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config содержит все параметры для создания Server.
|
// Config содержит все параметры для создания Server.
|
||||||
@@ -84,8 +76,6 @@ type Config struct {
|
|||||||
Authenticator auth.Authenticator // слой аутентификации
|
Authenticator auth.Authenticator // слой аутентификации
|
||||||
LLMUrl string
|
LLMUrl string
|
||||||
LLMKey string
|
LLMKey string
|
||||||
Billing billing.Store // слой статистики (NoopStore если не задан)
|
|
||||||
Stats stats.StatsProvider // аналитика (NoopProvider если не настроен)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer создаёт и настраивает HTTP Server со всеми зависимостями.
|
// NewServer создаёт и настраивает HTTP Server со всеми зависимостями.
|
||||||
@@ -106,8 +96,6 @@ func NewServer(cfg Config) *Server {
|
|||||||
llmURL: cfg.LLMUrl,
|
llmURL: cfg.LLMUrl,
|
||||||
llmKey: cfg.LLMKey,
|
llmKey: cfg.LLMKey,
|
||||||
nsManager: cloud.NewNSManager(cfg.Dyn),
|
nsManager: cloud.NewNSManager(cfg.Dyn),
|
||||||
billing: cfg.Billing,
|
|
||||||
stats: cfg.Stats,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,13 +157,8 @@ 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))
|
||||||
@@ -393,17 +393,7 @@ 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 {
|
||||||
@@ -411,13 +401,8 @@ 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 := freshFunctionSet[fnName]; !exists {
|
if _, exists := activeFunctions[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,8 +12,6 @@ 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"}
|
||||||
+2
-13
@@ -28,20 +28,9 @@ 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
|
||||||
existing, getErr := dyn.Resource(EnvironmentGVR).Namespace(ns).Get(ctx, envName, metav1.GetOptions{})
|
_, 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,14 +5,11 @@ 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"`
|
||||||
@@ -29,32 +26,9 @@ 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +44,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.1"},
|
"python": {Image: "naeel/fission-python-env:v1.0"},
|
||||||
"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,9 +11,6 @@ package runtime
|
|||||||
func DefaultEntrypoint(lang string) string {
|
func DefaultEntrypoint(lang string) string {
|
||||||
switch lang {
|
switch lang {
|
||||||
case "nodejs":
|
case "nodejs":
|
||||||
// "main" (без .js): node-env.specializeV2 делает split(".") для functionName
|
|
||||||
// с точкой → funcname="js" → ищет экспорт "js" → 500.
|
|
||||||
// Без расширения: funcname=undefined → берёт default export (module.exports = __invoke).
|
|
||||||
return "main"
|
return "main"
|
||||||
case "php":
|
case "php":
|
||||||
// Fission php-env: filename::functionName
|
// Fission php-env: filename::functionName
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildZip создаёт zip-архив с одним файлом fileName и содержимым content.
|
||||||
|
// Вспомогательная функция: используется в buildScriptZip (PHP/Ruby),
|
||||||
|
// а также как основа для buildGoSourceZip и buildJSDeployZip.
|
||||||
|
func buildZip(fileName string, content []byte) ([]byte, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
zw := zip.NewWriter(&buf)
|
||||||
|
|
||||||
|
fw, err := zw.Create(fileName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := fw.Write(content); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := zw.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
@@ -23,12 +23,10 @@ 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 и require как параметры → пользовательский CJS код работает
|
// 2. Передаём ей module и exports как параметры → пользовательский CJS код работает
|
||||||
// 3. require передаётся явно, т.к. new Function выполняется в глобальном scope
|
// 3. Экспортируем main/handler/default через module.exports
|
||||||
// и не имеет доступа к 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', 'require', %s))(__mod, __mod.exports, require);
|
(new Function('module', 'exports', %s))(__mod, __mod.exports);
|
||||||
const _fn = __mod.exports;
|
const _fn = __mod.exports;
|
||||||
|
|
||||||
async function __invoke(ctx) {
|
async function __invoke(ctx) {
|
||||||
+9
-11
@@ -33,20 +33,18 @@ func TestBuildJSDeployZipExportsMainAndHandler(t *testing.T) {
|
|||||||
files[file.Name] = string(content)
|
files[file.Name] = string(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if files["package.json"] != `{"type":"module"}` {
|
||||||
|
t.Fatalf("package.json = %q, want ESM marker", files["package.json"])
|
||||||
|
}
|
||||||
|
|
||||||
mainJS := files["main.js"]
|
mainJS := files["main.js"]
|
||||||
if mainJS == "" {
|
if !strings.Contains(mainJS, `export { __invoke as main, __invoke as handler };`) {
|
||||||
t.Fatalf("main.js not found in zip, files: %v", files)
|
t.Fatalf("main.js does not export both main and handler: %s", mainJS)
|
||||||
}
|
}
|
||||||
if !strings.Contains(mainJS, `new Function('module', 'exports', 'require',`) {
|
if !strings.Contains(mainJS, `export default __invoke;`) {
|
||||||
t.Fatalf("main.js does not pass require to new Function: %s", mainJS)
|
t.Fatalf("main.js does not export default invoke: %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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package runtime
|
||||||
|
|
||||||
|
// BuildScriptZip создаёт zip-архив с одним файлом fileName и содержимым code.
|
||||||
|
// Используется для PHP и Ruby — языков где среда Fission ожидает
|
||||||
|
// именованный файл (handler.rb, handler.pl, main.php и т.д.) внутри архива.
|
||||||
|
//
|
||||||
|
// Почему zip, а не просто literal:
|
||||||
|
// Fission poolmgr при специализации пода распаковывает deployment archive,
|
||||||
|
// находит нужный файл по имени и загружает его в среду выполнения.
|
||||||
|
// Если передать просто байты кода в deployment.literal — среда не знает расширение.
|
||||||
|
func BuildScriptZip(code, fileName string) ([]byte, error) {
|
||||||
|
return buildZip(fileName, []byte(code))
|
||||||
|
}
|
||||||
@@ -25,7 +25,6 @@
|
|||||||
<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>
|
||||||
@@ -103,14 +102,13 @@
|
|||||||
<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.96</div>
|
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.69</div>
|
||||||
</div>
|
</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>
|
||||||
@@ -137,10 +135,6 @@
|
|||||||
<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">
|
||||||
@@ -148,23 +142,10 @@
|
|||||||
<div style="font-weight:600;">Функции</div>
|
<div style="font-weight:600;">Функции</div>
|
||||||
<div class="hint">Actions: view, edit code, invoke, delete</div>
|
<div class="hint">Actions: view, edit code, invoke, delete</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Легенда статусов -->
|
|
||||||
<div style="margin-bottom:12px; padding:8px; background:var(--bg-alt); border-radius:4px; font-size:0.9em; color:var(--text-secondary);">
|
|
||||||
<strong>Статусы функций:</strong>
|
|
||||||
<span style="margin-left:16px;">
|
|
||||||
<span style="display:inline-block; width:10px; height:10px; border-radius:50%; background:#2a2; vertical-align:middle;"></span> Готова
|
|
||||||
<span style="margin-left:12px; display:inline-block; width:10px; height:10px; border-radius:50%; background:#22a; vertical-align:middle;"></span> Холодная
|
|
||||||
<span style="margin-left:12px; display:inline-block; width:10px; height:10px; border-radius:50%; background:#aa2; vertical-align:middle;"></span> Сборка
|
|
||||||
<span style="margin-left:12px; display:inline-block; width:10px; height:10px; border-radius:50%; background:#a22; vertical-align:middle;"></span> Ошибка
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Имя</th>
|
<th>Имя</th>
|
||||||
<th title="Статус функции (Готова / Холодная / Сборка / Ошибка)">Статус</th>
|
|
||||||
<th title="Тип источника функции">Тип</th>
|
<th title="Тип источника функции">Тип</th>
|
||||||
<th>Создана</th>
|
<th>Создана</th>
|
||||||
<th>Изменена</th>
|
<th>Изменена</th>
|
||||||
@@ -179,72 +160,6 @@
|
|||||||
<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-) -->
|
||||||
@@ -306,12 +221,6 @@
|
|||||||
<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"
|
||||||
@@ -451,12 +360,6 @@ psycopg2-binary" style="width:100%; box-sizing:border-box; font-family:monospace
|
|||||||
<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);">
|
||||||
@@ -473,31 +376,7 @@ psycopg2-binary" style="width:100%; box-sizing:border-box; font-family:monospace
|
|||||||
style="display:none;margin-top:8px;padding:10px 12px;border-radius:6px;font-size:13px;line-height:1.5;white-space:pre-wrap;font-family:monospace;">
|
style="display:none;margin-top:8px;padding:10px 12px;border-radius:6px;font-size:13px;line-height:1.5;white-space:pre-wrap;font-family:monospace;">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Переменные окружения -->
|
|
||||||
<div style="margin-top:16px;">
|
|
||||||
<div style="font-weight:600; margin-bottom:8px; font-size:13px;">Переменные окружения</div>
|
|
||||||
<div id="e-tf-env-warn" style="display:none; margin-bottom:8px; padding:6px 10px; background:#553300; color:#ffa; border-radius:4px; font-size:12px;">
|
|
||||||
⚠️ Функция управляется Terraform — переменные только для чтения
|
|
||||||
</div>
|
|
||||||
<div id="e-envvars-list" style="display:flex; flex-direction:column; gap:4px; margin-bottom:8px;"></div>
|
|
||||||
<button id="e-add-envvar-btn" class="btn ghost" style="font-size:12px;" onclick="addEnvVarRow('e')">+ Добавить переменную</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="e-error-msg" style="display:none;color:#f66;font-size:0.85rem;margin-bottom:8px;"></div>
|
|
||||||
|
|
||||||
<div id="e-clone-area" style="display:none;margin-top:10px;padding:10px 12px;background:var(--bg-alt,#2a2a3a);border-radius:6px;border:1px solid var(--border,#444);">
|
|
||||||
<div style="font-size:0.82rem;color:var(--fg-muted,#aaa);margin-bottom:6px;">Имя новой функции (копии):</div>
|
|
||||||
<div style="display:flex;gap:8px;align-items:center;">
|
|
||||||
<input id="e-clone-name" placeholder="new-function-name" style="flex:1;font-size:0.85rem;">
|
|
||||||
<button class="btn" onclick="submitClone()">Клонировать</button>
|
|
||||||
<button class="btn ghost" onclick="toggleCloneArea(false)">✕</button>
|
|
||||||
</div>
|
|
||||||
<div id="e-clone-error" style="display:none;color:#f66;font-size:0.78rem;margin-top:5px;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn ghost" onclick="toggleCloneArea(true)" title="Создать копию функции">📋 Клон</button>
|
|
||||||
<button class="btn ghost" onclick="closeEdit()">Отмена</button>
|
<button class="btn ghost" onclick="closeEdit()">Отмена</button>
|
||||||
<button id="e-submit" class="btn" onclick="submitEdit()">Сохранить</button>
|
<button id="e-submit" class="btn" onclick="submitEdit()">Сохранить</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -561,26 +440,6 @@ psycopg2-binary" style="width:100%; box-sizing:border-box; font-family:monospace
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="help-section">
|
|
||||||
<h4>Переменные окружения функции</h4>
|
|
||||||
<div class="help-list">
|
|
||||||
<div>В редакторе функции (кнопка <strong>Ред.</strong>) есть раздел <strong>Переменные окружения</strong>.</div>
|
|
||||||
<div>Нажмите <strong>+ Добавить</strong> чтобы задать переменную: введите имя и значение, нажмите <strong>Сохранить env vars</strong>.</div>
|
|
||||||
<div>Переменные доступны внутри функции через <span class="mono">os.environ</span> (Python), <span class="mono">process.env</span> (Node.js), <span class="mono">os.Getenv</span> (Go) и т.д.</div>
|
|
||||||
<div>Изменения применяются к запущенному поду функции сразу после сохранения.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-section">
|
|
||||||
<h4>Клонирование функции</h4>
|
|
||||||
<div class="help-list">
|
|
||||||
<div>В редакторе функции (кнопка <strong>Ред.</strong>) есть кнопка <strong>📋 Клон</strong> в нижней панели.</div>
|
|
||||||
<div>Введите имя для новой функции и нажмите <strong>Клонировать</strong>.</div>
|
|
||||||
<div>Клон получает тот же код, язык, entrypoint, timeout и env vars. Маршрут назначается автоматически.</div>
|
|
||||||
<div>Имя: строчные буквы, цифры, дефис; не начинается и не заканчивается дефисом; до 57 символов.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-section">
|
<div class="help-section">
|
||||||
<h4>Как вызвать функцию вне веб-интерфейса</h4>
|
<h4>Как вызвать функцию вне веб-интерфейса</h4>
|
||||||
<div class="help-list">
|
<div class="help-list">
|
||||||
@@ -626,7 +485,7 @@ psycopg2-binary" style="width:100%; box-sizing:border-box; font-family:monospace
|
|||||||
</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.96</span>
|
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.69</span>
|
||||||
<button class="btn ghost" onclick="closeHelp()">Закрыть</button>
|
<button class="btn ghost" onclick="closeHelp()">Закрыть</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,43 +1,5 @@
|
|||||||
/* 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) {
|
||||||
@@ -83,25 +45,7 @@ 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';
|
||||||
var resultText = data.result || '(пустой ответ)';
|
resEl.textContent = 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,9 +16,6 @@ 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 || [];
|
||||||
@@ -51,20 +48,6 @@ async function reloadAll() {
|
|||||||
const createdAt = ann['fission-console/created-at'] || meta.creationTimestamp || '-';
|
const createdAt = ann['fission-console/created-at'] || meta.creationTimestamp || '-';
|
||||||
const updatedAt = ann['fission-console/updated-at'] || createdAt;
|
const updatedAt = ann['fission-console/updated-at'] || createdAt;
|
||||||
const sourceType = ann['fission-console/source-type'] || 'code';
|
const sourceType = ann['fission-console/source-type'] || 'code';
|
||||||
const status = ann['fission-console/status'] || 'Cold';
|
|
||||||
|
|
||||||
// Статус функции: иконка и цвет
|
|
||||||
const statusColor = {
|
|
||||||
'Ready': '#2a2', 'Cold': '#22a', 'Building': '#aa2', 'Error': '#a22'
|
|
||||||
}[status] || '#666';
|
|
||||||
const statusTitle = {
|
|
||||||
'Ready': 'Готова (есть pod)',
|
|
||||||
'Cold': 'Холодная (pod создаётся при первом вызове)',
|
|
||||||
'Building': 'Собирается / деплоится',
|
|
||||||
'Error': 'Ошибка (pod в CrashLoopBackOff или build failed)'
|
|
||||||
}[status] || 'Неизвестно';
|
|
||||||
const statusDot = '<span style="display:inline-block; width:12px; height:12px; border-radius:50%; background:' + statusColor + '; cursor:default;" title="' + h(statusTitle) + '"></span>';
|
|
||||||
|
|
||||||
const sourceIcon = sourceType === 'archive'
|
const sourceIcon = sourceType === 'archive'
|
||||||
? '<span title="Из архива (.zip)" style="font-size:1.1em; cursor:default;">📦</span>'
|
? '<span title="Из архива (.zip)" style="font-size:1.1em; cursor:default;">📦</span>'
|
||||||
: '<span title="Из кода (редактор)" style="font-size:1.1em; cursor:default;">📝</span>';
|
: '<span title="Из кода (редактор)" style="font-size:1.1em; cursor:default;">📝</span>';
|
||||||
@@ -75,7 +58,7 @@ async function reloadAll() {
|
|||||||
var infoData = h(JSON.stringify({
|
var infoData = h(JSON.stringify({
|
||||||
name: name, env: env, pkg: pkg, entrypoint: entrypoint, timeout: timeout,
|
name: name, env: env, pkg: pkg, entrypoint: entrypoint, timeout: timeout,
|
||||||
route: route, methods: methods, sourceType: sourceType,
|
route: route, methods: methods, sourceType: sourceType,
|
||||||
createdAt: createdAt, updatedAt: updatedAt, cron: cron, status: status
|
createdAt: createdAt, updatedAt: updatedAt, cron: cron
|
||||||
}));
|
}));
|
||||||
var actions = tfBadge +
|
var actions = tfBadge +
|
||||||
'<button class="btn ghost" onclick="openInfo(\'' + h(name) + '\', this)" data-info="' + infoData + '">Info</button> ' +
|
'<button class="btn ghost" onclick="openInfo(\'' + h(name) + '\', this)" data-info="' + infoData + '">Info</button> ' +
|
||||||
@@ -85,7 +68,6 @@ async function reloadAll() {
|
|||||||
'<button class="btn danger" onclick="removeFn(\'' + h(name) + '\')">Удалить</button>';
|
'<button class="btn danger" onclick="removeFn(\'' + h(name) + '\')">Удалить</button>';
|
||||||
return '<tr>' +
|
return '<tr>' +
|
||||||
'<td class="mono">' + h(name) + '</td>' +
|
'<td class="mono">' + h(name) + '</td>' +
|
||||||
'<td style="text-align:center;">' + statusDot + '</td>' +
|
|
||||||
'<td style="text-align:center;">' + sourceIcon + '</td>' +
|
'<td style="text-align:center;">' + sourceIcon + '</td>' +
|
||||||
'<td>' + timestampCell(createdAt) + '</td>' +
|
'<td>' + timestampCell(createdAt) + '</td>' +
|
||||||
'<td>' + timestampCell(updatedAt) + '</td>' +
|
'<td>' + timestampCell(updatedAt) + '</td>' +
|
||||||
@@ -93,27 +93,6 @@ 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,23 +7,6 @@ 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() {
|
||||||
@@ -33,7 +16,6 @@ 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');
|
||||||
@@ -65,8 +47,7 @@ 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 {
|
||||||
@@ -151,10 +151,6 @@ function parseTimeout(v) {
|
|||||||
|
|
||||||
async function openEdit(name) {
|
async function openEdit(name) {
|
||||||
try {
|
try {
|
||||||
var errEl = document.getElementById('e-error-msg');
|
|
||||||
if (errEl) errEl.style.display = 'none';
|
|
||||||
var cloneArea = document.getElementById('e-clone-area');
|
|
||||||
if (cloneArea) cloneArea.style.display = 'none';
|
|
||||||
const fn = await getJSON(API_BASE + '/functions/' + encodeURIComponent(name));
|
const fn = await getJSON(API_BASE + '/functions/' + encodeURIComponent(name));
|
||||||
S.currentEdit = fn;
|
S.currentEdit = fn;
|
||||||
document.getElementById('e-title').textContent = 'Редактирование: ' + name;
|
document.getElementById('e-title').textContent = 'Редактирование: ' + name;
|
||||||
@@ -188,13 +184,6 @@ 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');
|
||||||
@@ -202,11 +191,6 @@ async function openEdit(name) {
|
|||||||
var isTf = /^tf-/.test(name) || /go[-_]env/.test(fn.environment || '');
|
var isTf = /^tf-/.test(name) || /go[-_]env/.test(fn.environment || '');
|
||||||
warnEl.style.display = isTf ? 'block' : 'none';
|
warnEl.style.display = isTf ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Env vars — отрисовать блок; TF-функции — только чтение
|
|
||||||
var isTfFn = /^tf-/.test(name) || /go[-_]env/.test(fn.environment || '');
|
|
||||||
renderEnvVars('e', fn.env_vars || [], isTfFn);
|
|
||||||
|
|
||||||
document.getElementById('edit-modal').classList.add('open');
|
document.getElementById('edit-modal').classList.add('open');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showStatus('Ошибка загрузки функции: ' + e.message, 'err');
|
showStatus('Ошибка загрузки функции: ' + e.message, 'err');
|
||||||
@@ -249,42 +233,16 @@ 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)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await syncScheduleForFunction(name, 'e');
|
await syncScheduleForFunction(name, 'e');
|
||||||
|
|
||||||
// Сохраняем env vars (только если не TF-функция)
|
|
||||||
var isTfFn = /^tf-/.test(name);
|
|
||||||
if (!isTfFn) {
|
|
||||||
var envVars = collectEnvVars('e');
|
|
||||||
// Проверка формата ключей
|
|
||||||
var invalidKeys = envVars.map(function(ev) { return ev.name; }).filter(function(k) { return !ENV_KEY_RE.test(k); });
|
|
||||||
if (invalidKeys.length > 0) {
|
|
||||||
throw new Error('Недопустимые имена переменных: ' + invalidKeys.join(', ') + ' (только латиница, цифры, _, не с цифры)');
|
|
||||||
}
|
|
||||||
// Проверка на дубли ключей
|
|
||||||
var keys = envVars.map(function(ev) { return ev.name; });
|
|
||||||
var dupes = keys.filter(function(k, i) { return keys.indexOf(k) !== i; });
|
|
||||||
if (dupes.length > 0) {
|
|
||||||
throw new Error('Дублирующиеся ключи: ' + [...new Set(dupes)].join(', '));
|
|
||||||
}
|
|
||||||
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/envvars', 'PUT', {
|
|
||||||
env_vars: envVars
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var errEl = document.getElementById('e-error-msg');
|
|
||||||
if (errEl) errEl.style.display = 'none';
|
|
||||||
closeEdit();
|
closeEdit();
|
||||||
progress.stop('Код обновлён: ' + name, 'ok');
|
progress.stop('Код обновлён: ' + name, 'ok');
|
||||||
await reloadAll();
|
await reloadAll();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
var errEl = document.getElementById('e-error-msg');
|
progress.stop('Ошибка обновления: ' + e.message, 'err');
|
||||||
if (errEl) { errEl.textContent = e.message; errEl.style.display = 'block'; }
|
|
||||||
progress.stop('', '');
|
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -302,180 +260,3 @@ async function removeFn(name) {
|
|||||||
progress.stop('Ошибка удаления: ' + e.message, 'err');
|
progress.stop('Ошибка удаления: ' + e.message, 'err');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Env Vars UI ---
|
|
||||||
|
|
||||||
// renderEnvVars отрисовывает список переменных окружения в блоке prefix-envvars-list
|
|
||||||
// vars: [{name: "KEY", value: "VAL"}, ...]
|
|
||||||
// readOnly: true для TF-функций
|
|
||||||
function renderEnvVars(prefix, vars, readOnly) {
|
|
||||||
var list = document.getElementById(prefix + '-envvars-list');
|
|
||||||
var addBtn = document.getElementById(prefix + '-add-envvar-btn');
|
|
||||||
var tfWarn = document.getElementById(prefix + '-tf-env-warn');
|
|
||||||
if (!list) return;
|
|
||||||
list.innerHTML = '';
|
|
||||||
if (readOnly) {
|
|
||||||
if (tfWarn) tfWarn.style.display = '';
|
|
||||||
if (addBtn) addBtn.style.display = 'none';
|
|
||||||
} else {
|
|
||||||
if (tfWarn) tfWarn.style.display = 'none';
|
|
||||||
if (addBtn) addBtn.style.display = '';
|
|
||||||
}
|
|
||||||
(vars || []).forEach(function(ev, idx) {
|
|
||||||
list.appendChild(makeEnvVarRow(prefix, ev.name || '', ev.value || '', readOnly, idx));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// makeEnvVarRow создаёт одну строку key=value с кнопкой удаления
|
|
||||||
function makeEnvVarRow(prefix, key, val, readOnly, idx) {
|
|
||||||
var row = document.createElement('div');
|
|
||||||
row.style.cssText = 'display:flex; gap:6px; align-items:center;';
|
|
||||||
row.dataset.envIdx = idx;
|
|
||||||
|
|
||||||
var kInput = document.createElement('input');
|
|
||||||
kInput.placeholder = 'KEY (A-Z, a-z, 0-9, _)';
|
|
||||||
kInput.value = key;
|
|
||||||
kInput.disabled = readOnly;
|
|
||||||
kInput.style.cssText = 'flex:1; font-size:12px; font-family:monospace;';
|
|
||||||
kInput.dataset.envKey = '1';
|
|
||||||
// Подсветка дублей при изменении ключа
|
|
||||||
kInput.addEventListener('input', function() { highlightDupeKeys(prefix); });
|
|
||||||
|
|
||||||
var vInput = document.createElement('input');
|
|
||||||
vInput.placeholder = 'value';
|
|
||||||
vInput.value = val;
|
|
||||||
vInput.disabled = readOnly;
|
|
||||||
vInput.style.cssText = 'flex:2; font-size:12px; font-family:monospace;';
|
|
||||||
vInput.dataset.envVal = '1';
|
|
||||||
|
|
||||||
row.appendChild(kInput);
|
|
||||||
|
|
||||||
var eq = document.createElement('span');
|
|
||||||
eq.textContent = '=';
|
|
||||||
eq.style.cssText = 'color:var(--text-secondary); font-family:monospace;';
|
|
||||||
row.appendChild(eq);
|
|
||||||
row.appendChild(vInput);
|
|
||||||
|
|
||||||
if (!readOnly) {
|
|
||||||
var delBtn = document.createElement('button');
|
|
||||||
delBtn.textContent = '×';
|
|
||||||
delBtn.className = 'btn ghost';
|
|
||||||
delBtn.style.cssText = 'padding:2px 8px; font-size:14px; line-height:1;';
|
|
||||||
delBtn.title = 'Удалить переменную';
|
|
||||||
delBtn.onclick = function() { row.remove(); };
|
|
||||||
row.appendChild(delBtn);
|
|
||||||
}
|
|
||||||
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
var ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
||||||
|
|
||||||
// highlightDupeKeys подсвечивает красным все поля KEY с одинаковыми именами или невалидным именем
|
|
||||||
function highlightDupeKeys(prefix) {
|
|
||||||
var list = document.getElementById(prefix + '-envvars-list');
|
|
||||||
if (!list) return;
|
|
||||||
var inputs = list.querySelectorAll('[data-env-key]');
|
|
||||||
var keys = Array.from(inputs).map(function(i) { return i.value.trim(); });
|
|
||||||
inputs.forEach(function(inp) {
|
|
||||||
var k = inp.value.trim();
|
|
||||||
var isDupe = k !== '' && keys.filter(function(x) { return x === k; }).length > 1;
|
|
||||||
var isInvalid = k !== '' && !ENV_KEY_RE.test(k);
|
|
||||||
inp.style.outline = (isDupe || isInvalid) ? '2px solid #f44' : '';
|
|
||||||
inp.title = isDupe ? 'Дублирующийся ключ!' : isInvalid ? 'Только латиница, цифры и _. Не начинается с цифры.' : '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// addEnvVarRow добавляет пустую строку в список env vars
|
|
||||||
function addEnvVarRow(prefix) {
|
|
||||||
var list = document.getElementById(prefix + '-envvars-list');
|
|
||||||
if (!list) return;
|
|
||||||
var idx = list.children.length;
|
|
||||||
list.appendChild(makeEnvVarRow(prefix, '', '', false, idx));
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectEnvVars читает текущие значения из DOM и возвращает [{name, value}, ...]
|
|
||||||
function collectEnvVars(prefix) {
|
|
||||||
var list = document.getElementById(prefix + '-envvars-list');
|
|
||||||
if (!list) return [];
|
|
||||||
var result = [];
|
|
||||||
var rows = list.querySelectorAll('div[data-env-idx]');
|
|
||||||
rows.forEach(function(row) {
|
|
||||||
var k = row.querySelector('[data-env-key]');
|
|
||||||
var v = row.querySelector('[data-env-val]');
|
|
||||||
var key = k ? k.value.trim() : '';
|
|
||||||
var val = v ? v.value : '';
|
|
||||||
if (key) result.push({name: key, value: val});
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// toggleCloneArea показывает/скрывает форму клонирования
|
|
||||||
function toggleCloneArea(show) {
|
|
||||||
var area = document.getElementById('e-clone-area');
|
|
||||||
var errEl = document.getElementById('e-clone-error');
|
|
||||||
if (!area) return;
|
|
||||||
area.style.display = show ? 'block' : 'none';
|
|
||||||
if (show) {
|
|
||||||
var nameInput = document.getElementById('e-clone-name');
|
|
||||||
if (nameInput) {
|
|
||||||
// Предзаполняем именем оригинала + '-copy'
|
|
||||||
var srcName = (S.currentEdit && S.currentEdit.name) || '';
|
|
||||||
nameInput.value = srcName ? srcName + '-copy' : '';
|
|
||||||
nameInput.focus();
|
|
||||||
nameInput.select();
|
|
||||||
}
|
|
||||||
if (errEl) errEl.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// submitClone отправляет запрос на клонирование функции
|
|
||||||
async function submitClone() {
|
|
||||||
var nameInput = document.getElementById('e-clone-name');
|
|
||||||
var errEl = document.getElementById('e-clone-error');
|
|
||||||
var btn = document.querySelector('#e-clone-area .btn:not(.ghost)');
|
|
||||||
|
|
||||||
var newName = nameInput ? nameInput.value.trim() : '';
|
|
||||||
if (!newName) {
|
|
||||||
if (errEl) { errEl.textContent = 'Введите имя новой функции'; errEl.style.display = 'block'; }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(newName) || newName.length > 57) {
|
|
||||||
if (errEl) { errEl.textContent = 'Имя: строчные буквы, цифры, дефис; не начинается/заканчивается дефисом; до 57 символов'; errEl.style.display = 'block'; }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var srcName = S.currentEdit && S.currentEdit.name;
|
|
||||||
if (!srcName) {
|
|
||||||
if (errEl) { errEl.textContent = 'Нет функции для клонирования'; errEl.style.display = 'block'; }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (btn) { btn.disabled = true; btn.textContent = '...'; }
|
|
||||||
if (errEl) errEl.style.display = 'none';
|
|
||||||
|
|
||||||
try {
|
|
||||||
var data = await requestJSON(API_BASE + '/functions/' + encodeURIComponent(srcName) + '/clone', 'POST', {new_name: newName});
|
|
||||||
// Успех — закрываем модалку, ждём появления в списке
|
|
||||||
closeEdit();
|
|
||||||
showStatus('Клонирование завершено, обновляем список...', '');
|
|
||||||
var attempts = 0;
|
|
||||||
async function pollUntilVisible() {
|
|
||||||
attempts++;
|
|
||||||
await reloadAll();
|
|
||||||
var found = (S.fns || []).some(function(f) {
|
|
||||||
return f && f.metadata && f.metadata.name === newName;
|
|
||||||
});
|
|
||||||
if (found || attempts >= 5) {
|
|
||||||
showStatus('Функция «' + newName + '» создана как копия «' + srcName + '»', 'ok');
|
|
||||||
} else {
|
|
||||||
setTimeout(pollUntilVisible, 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setTimeout(pollUntilVisible, 700);
|
|
||||||
} catch(e) {
|
|
||||||
if (errEl) { errEl.textContent = e.message; errEl.style.display = 'block'; }
|
|
||||||
} finally {
|
|
||||||
if (btn) { btn.disabled = false; btn.textContent = 'Клонировать'; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user