Compare commits

...
17 Commits
Author SHA1 Message Date
Naeel 50ba09e1b6 Merge feat/console-auth: auth через deck API, login overlay, logout 2026-04-19 09:43:55 +03:00
Naeel 903610bf71 chore: copilot instructions update 2026-04-19 09:43:55 +03:00
Naeel 0a1708e682 doc: тест auth v0.5.0 (2026-04-19) 2026-04-19 08:50:34 +03:00
Naeel 579bf97e17 console v0.5.0: auth через deck API, login overlay, logout 2026-04-19 08:25:23 +03:00
Naeel c40fc47025 console v0.4.5: русификация UI, Go/TF предупреждения, handler.go в preferred 2026-04-15 19:47:41 +03:00
Naeel fd236d87ea feat: provider audit — builder support, function tuning, source archive
Аудит провайдера vs каноничный Fission. Добавлено:

Environment:
- builder_image, builder_command для Go и языков с build step

Package:
- deploy_type (literal/source) для переключения deployment/source archive
- loadPackageSourceArchive() — zip-упаковка source_dir
- Убран пустой source:{} из literal mode

Function:
- executor_type (poolmgr/newdeploy/container)
- function_timeout, idle_timeout
- min_scale, max_scale для ExecutionStrategy

Все 21 тест пройден. Обратная совместимость проверена.
Документация: doc/AUDIT_PROVIDER_VS_FISSION_2026-06-03.md
2026-04-15 17:46:47 +03:00
Naeel 6d66e5b566 feat: multi-language support — PHP, Ruby, Perl, Go (binary-env)
- PHP: ghcr.io/fission/php-env, entrypoint main.php::handler
- Ruby: ghcr.io/fission/ruby-env, entrypoint handler (single-word)
- Perl: ghcr.io/fission/perl-env (version=1), sub {} return value
- Go: ghcr.io/fission/binary-env with pre-compiled binary (shell script placeholder)

Provider changes:
- package_resource: added main.php, main.rb, main.pl to source_dir candidates
- function_resource: relaxed entrypoint validation to allow PHP (::), Ruby, Perl (single-word)
- function_resource_test: updated BadFormat test to reflect new valid formats
2026-04-15 17:01:40 +03:00
Naeel c78b539d24 doc: plan for multi-language support (Go, Java, PHP, Ruby, .NET, Perl) 2026-04-15 16:31:34 +03:00
Naeel cccc5ca024 revert: remove Go function (runtime compilation timeout) 2026-04-15 16:24:57 +03:00
Naeel 66404fde3a add: Go function example (tf-go-hello-fn) 2026-04-15 16:19:25 +03:00
Naeel 4964c8f357 console: fix favicon — full base64 data URI (v0.3.4) 2026-04-15 15:20:27 +03:00
Naeel 7e23a71574 console: embed favicon as data URI (v0.3.3) 2026-04-15 15:15:21 +03:00
Naeel ab2eff69a0 ui: use terra.k8c.ru favicon (same as qu.kube5s.ru/ui/) 2026-04-15 15:08:58 +03:00
Naeel a199745c77 fix: restore 15 functions and roll console favicon image 2026-04-15 15:05:27 +03:00
Naeel ba32389600 ui: use nubes favicon url 2026-04-15 14:51:10 +03:00
Naeel 8ac8428a76 docs: record strict cleanup to 6 verified functions 2026-04-15 14:43:58 +03:00
Naeel ba50d18f8f fix(console): sync function package resourceversion after code update 2026-04-15 11:14:42 +03:00
65 changed files with 3409 additions and 384 deletions
+11 -220
View File
@@ -1,223 +1,14 @@
# Правила работы агента в проекте fission # Правила
## ГЛАВНОЕ ПРАВИЛО ## ⛔ ОТВЕЧАТЬ КРАТКО — АБСОЛЮТНОЕ ПРАВИЛО
- Вопрос → короткий ответ → СТОП. Не рассуждать, не исследовать без команды.
- НЕ делать ничего "попутно" без явной просьбы. Сделал — стоп — ждёшь команды.
**НЕ "СОВЕРШЕНСТВОВАТЬ" РАБОЧИЙ КОД БЕЗ ЯВНОГО УКАЗАНИЯ.** > Подробные правила: [`.github/pravila.md`](pravila.md)
--- 1. Не трогать рабочий код без явного указания.
2. Файлы редактировать локально — `~/remote_dev/` = `~/terra/` на ВМ (sshfs), SCP не нужен.
## Файловая система и выполнение команд 3. Все команды — **только через SSH**, никогда локально:
```bash
### Маппинг путей ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
```
Локальная папка `~/remote_dev/` примонтирована через sshfs к `~/terra/` на ВМ `5.172.178.213`.
Это **одна и та же файловая система**:
| Локально (VS Code) | На ВМ |
|----------------------------------|-------------------------------|
| `/home/naeel/remote_dev/fission` | `/home/naeel/terra/fission` |
| `/home/naeel/remote_dev/sless` | `/home/naeel/terra/sless` |
| `/home/naeel/remote_dev/IoT` | `/home/naeel/terra/IoT` |
Любой файл, сохранённый локально через VS Code, **мгновенно виден на ВМ**. SCP не нужен.
### Что можно делать локально
- **Редактировать файлы** — через VS Code, replace_string_in_file, create_file и т.д. Изменения сразу на ВМ.
- **Читать файлы** — read_file, grep_search, file_search, semantic_search — всё разрешено.
### Что ЗАПРЕЩЕНО локально
**Все команды выполнять ИСКЛЮЧИТЕЛЬНО через SSH на ВМ:**
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
```
**Причина:** локально может быть активен VPN, что вызывает сбои при выполнении сетевых команд, обращениях к кластеру, Docker и пр.
Запрещено запускать локально (без ssh):
- `go build`, `go test`, `go mod tidy`
- `docker build`, `docker push`
- `kubectl`, `helm`
- `terraform`
- `curl`, `wget` к кластерным сервисам
- `git push`, `git pull` (git операции — через ssh на ВМ)
- Любые bash-скрипты проекта
### Формат работы
1. Отредактировать файлы локально (VS Code / инструменты агента)
2. Запустить команды через SSH на ВМ
3. Ждать результата
---
## Документация
Всё важное фиксировать в `doc/`:
- `doc/thinking/` — лог рассуждений агента (обязательно)
- `doc/progress.md` — трекер задач
- Обновлять после каждого значимого изменения.
## Git
Коммитить и пушить после каждого завершённого этапа.
### Версионирование тегами
После каждого коммита с изменениями — поднимать версию тега:
```bash
# Посмотреть текущий тег
git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0"
# Поднять patch-версию (v0.1.0 → v0.1.1, v0.1.1 → v0.1.2 и т.д.)
git tag vX.Y.Z && git push origin vX.Y.Z
```
**Правила:**
- Patch (Z) — любое изменение: правка кода, новая функция, фикс
- Minor (Y) — новая фича/компонент (новый ресурс провайдера, новый модуль консоли)
- Major (X) — breaking change
- Тег ставить ПОСЛЕ успешного коммита и пуша
- Формат: `vMAJOR.MINOR.PATCH` (например `v0.2.3`)
---
## Как создать и задеплоить новую Fission-функцию через Terraform
### Пошаговая инструкция (ОБЯЗАТЕЛЬНО следовать)
#### Шаг 1: Создать папку с функцией
Создать папку в `examples/` по шаблону. Пример: `examples/my-test/`
Структура:
```
examples/my-test/
├── code/
│ └── main.py # Код функции
└── main.tf # Terraform манифест
```
#### Шаг 2: Написать код функции
Файл `code/main.py` — обязательно функция `main()`:
```python
def main():
return "hello from my-test"
```
Можно сложнее — с аргументами, JSON, вычислениями:
```python
import json
def main():
result = {"status": "ok", "data": [1, 2, 3]}
return json.dumps(result)
```
#### Шаг 3: Написать Terraform манифест
Файл `main.tf` — КОПИРОВАТЬ этот шаблон и менять только имена и URL:
```hcl
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "python" {
name = "УНИКАЛЬНОЕ-ИМЯ-env"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "pkg" {
name = "УНИКАЛЬНОЕ-ИМЯ-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "УНИКАЛЬНОЕ-ИМЯ-fn"
environment = fission_environment.python.name
package_name = fission_package.pkg.name
entrypoint = "main.main"
}
resource "fission_http_trigger" "route" {
name = "УНИКАЛЬНОЕ-ИМЯ-route"
function = fission_function.fn.name
url = "/УНИКАЛЬНЫЙ-URL"
methods = ["GET"]
}
```
**ВАЖНО:** Все имена ресурсов (name) должны быть **уникальными** по всему кластеру.
Используй префикс, например `tf-mytest-env`, `tf-mytest-pkg`, `tf-mytest-fn`, `tf-mytest-route`.
#### Шаг 4: Задеплоить через SSH
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 \
'cd ~/terra/fission/examples/my-test && TF_CLI_CONFIG_FILE=/tmp/terraformrc-fission terraform apply -auto-approve'
```
**ОБЯЗАТЕЛЬНО:**
- Переменная `TF_CLI_CONFIG_FILE=/tmp/terraformrc-fission` — ВСЕГДА нужна (dev override провайдера)
- `terraform init` НЕ нужен (dev_overrides пропускает init)
- Выполнять ТОЛЬКО через SSH
#### Шаг 5: Проверить функцию через curl
Роутер требует JWT. Паттерн:
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 '
PASSWORD=$(kubectl -n fission get secret router -o jsonpath={.data.password} | base64 -d)
TOKEN=$(curl -sk -X POST https://fission.kube5s.ru/auth/login \
-H "Content-Type: application/json" \
-d "{\"username\":\"admin\",\"password\":\"$PASSWORD\"}" \
| python3 -c "import sys,json; print(json.load(sys.stdin)[\"accesstoken\"])")
curl -sk -H "Authorization: Bearer $TOKEN" https://fission.kube5s.ru/УНИКАЛЬНЫЙ-URL
'
```
#### Шаг 6: Удалить (если нужно)
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 \
'cd ~/terra/fission/examples/my-test && TF_CLI_CONFIG_FILE=/tmp/terraformrc-fission terraform destroy -auto-approve'
```
### Уже задеплоенные функции (НЕ ТРОГАТЬ без явного указания)
| Папка | Endpoint | Ответ |
|-------|----------|-------|
| `examples/hello-python/` | `GET /tf-hello` | `hello from fission via terraform` |
| `examples/stress-suite/` | `GET /stress/fast` | `fast-ok` |
| `examples/stress-suite/` | `GET /stress/cpu` | `cpu-ok:NUMBER` |
| `examples/stress-suite/` | `GET /stress/json` | `{"status":"ok","service":"json"}` |
### Частые ошибки
| Ошибка | Причина | Решение |
|--------|---------|---------|
| `unauthorized: malformed token` | curl без JWT | Добавить JWT (см. шаг 5) |
| `terraform init required` | Забыт `TF_CLI_CONFIG_FILE` | Добавить `TF_CLI_CONFIG_FILE=/tmp/terraformrc-fission` |
| `resource already exists` | Имя не уникальное | Сменить имена на уникальные с префиксом |
| `Error: connection refused` | Команда запущена локально | Выполнять ТОЛЬКО через SSH |
| `package not found` | Опечатка в `source_dir` | Проверить path.module/code и файл main.py |
+56
View File
@@ -0,0 +1,56 @@
# Правила работы агента
## Файловая система
`~/remote_dev/` (локально) примонтирован через sshfs к `~/terra/` на ВМ — **одна ФС**.
Файлы, сохранённые локально, мгновенно видны на ВМ. SCP не нужен.
Монтирование может слетать. Признак: файлы рассинхронизированы.
```bash
# Размонтировать
fusermount -u ~/remote_dev
# Если завис: sudo umount -l /home/naeel/remote_dev
# Примонтировать
sshfs naeel@5.172.178.213:/home/naeel/terra ~/remote_dev \
-o cache=no -o no_readahead -o reconnect \
-o ServerAliveInterval=15 -o ServerAliveCountMax=3 \
-o IdentityFile=~/.ssh/naeel_vm_id_ed25519
```
## SSH
Все команды — только через SSH на ВМ. Локально — только читать и редактировать файлы.
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
```
Запрещено локально: `go`, `docker`, `kubectl`, `helm`, `terraform`, `curl/wget`, `git push/pull`, любые скрипты проекта.
## Документация
- `doc/thinking/` — лог рассуждений агента (обязательно)
- `doc/progress.md` — трекер задач
- Старые файлы `doc/` не перезаписывать — новое в новых файлах с датой
## Git
Коммитить и пушить через SSH после каждого завершённого этапа.
Версионирование тегами: `vMAJOR.MINOR.PATCH`
- Patch — любое изменение кода
- Minor — новая фича / компонент
- Major — breaking change
```bash
git tag vX.Y.Z && git push origin vX.Y.Z
```
## Поведение агента
- Не трогать рабочий код без явного указания
- Не делать ничего сверх того, о чём явно попросили
- Деструктивные операции (`kubectl delete`, `rm -rf`, `terraform destroy` и др.) — только после явного подтверждения с указанием конкретных объектов
- Отвечать кратко, без вступлений, извинений, благодарностей и прочей воды
+4
View File
@@ -8,6 +8,10 @@ bin/
*.test *.test
*.out *.out
# Compiled function binaries (generated, not versioned)
examples/*/dist/
# Provider binaries # Provider binaries
terraform-provider-fission terraform-provider-fission
terraform-provider-fission_* terraform-provider-fission_*
console/fission-console
+5 -1
View File
@@ -46,7 +46,7 @@ spec:
serviceAccountName: fission-console serviceAccountName: fission-console
containers: containers:
- name: console - name: console
image: naeel/fission-console:v0.2.4 image: naeel/fission-console:v0.3.4
ports: ports:
- containerPort: 8090 - containerPort: 8090
env: env:
@@ -56,6 +56,10 @@ spec:
value: "http://router.fission.svc.cluster.local" value: "http://router.fission.svc.cluster.local"
- name: PORT - name: PORT
value: "8090" value: "8090"
- name: FISSION_HTTP_TIMEOUT
value: "30s"
- name: FISSION_INVOKE_TIMEOUT
value: "20s"
- name: FISSION_AUTH_USERNAME - name: FISSION_AUTH_USERNAME
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
+362 -40
View File
@@ -6,9 +6,11 @@ import (
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log" "log"
"net"
"net/http" "net/http"
"os" "os"
"sort" "sort"
@@ -38,12 +40,19 @@ var (
const defaultSATokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" const defaultSATokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
var deckAPIs = map[string]string{
"prod": "https://deck-api.ngcloud.ru/api/v1",
"dev": "https://deck-api-dev.ngcloud.ru/api/v1",
"test": "https://deck-api-test.ngcloud.ru/api/v1",
}
type server struct { type server struct {
dyn dynamic.Interface dyn dynamic.Interface
ns string ns string
routerURL string routerURL string
http *http.Client http *http.Client
saTokenPath string saTokenPath string
invokeTimeout time.Duration
authUser string authUser string
authPass string authPass string
@@ -51,10 +60,12 @@ type server struct {
tokenMu sync.Mutex tokenMu sync.Mutex
cachedJWT string cachedJWT string
tokenExpAt time.Time tokenExpAt time.Time
tokenCache sync.Map
} }
type createFunctionRequest struct { type createFunctionRequest struct {
Name string `json:"name"` Name string `json:"name"`
Language string `json:"language"`
Environment string `json:"environment"` Environment string `json:"environment"`
Code string `json:"code"` Code string `json:"code"`
Entrypoint string `json:"entrypoint"` Entrypoint string `json:"entrypoint"`
@@ -62,6 +73,20 @@ type createFunctionRequest struct {
Methods []string `json:"methods"` Methods []string `json:"methods"`
} }
type langEnvDef struct {
Image string
BuilderImage string
}
var langEnvMap = map[string]langEnvDef{
"python": {Image: "ghcr.io/fission/python-env"},
"nodejs": {Image: "ghcr.io/fission/node-env"},
"go": {Image: "ghcr.io/fission/go-env", BuilderImage: "ghcr.io/fission/go-builder"},
"php": {Image: "ghcr.io/fission/php-env"},
"ruby": {Image: "ghcr.io/fission/ruby-env"},
"perl": {Image: "ghcr.io/fission/perl-env"},
}
type updateCodeRequest struct { type updateCodeRequest struct {
Code string `json:"code"` Code string `json:"code"`
} }
@@ -71,6 +96,8 @@ func main() {
namespace := envDefault("FISSION_NAMESPACE", "default") namespace := envDefault("FISSION_NAMESPACE", "default")
routerURL := strings.TrimRight(envDefault("FISSION_ROUTER_URL", "http://router.fission.svc.cluster.local"), "/") routerURL := strings.TrimRight(envDefault("FISSION_ROUTER_URL", "http://router.fission.svc.cluster.local"), "/")
port := envDefault("PORT", "8090") port := envDefault("PORT", "8090")
httpTimeout := envDurationDefault("FISSION_HTTP_TIMEOUT", 30*time.Second)
invokeTimeout := envDurationDefault("FISSION_INVOKE_TIMEOUT", 20*time.Second)
cfg, err := buildConfig(kubeconfig) cfg, err := buildConfig(kubeconfig)
if err != nil { if err != nil {
@@ -87,13 +114,14 @@ func main() {
saTokenPath := envDefault("SA_TOKEN_PATH", defaultSATokenPath) saTokenPath := envDefault("SA_TOKEN_PATH", defaultSATokenPath)
s := &server{ s := &server{
dyn: dyn, dyn: dyn,
ns: namespace, ns: namespace,
routerURL: routerURL, routerURL: routerURL,
http: &http.Client{Timeout: 30 * time.Second}, http: &http.Client{Timeout: httpTimeout},
saTokenPath: saTokenPath, saTokenPath: saTokenPath,
authUser: authUser, invokeTimeout: invokeTimeout,
authPass: authPass, authUser: authUser,
authPass: authPass,
} }
mux := http.NewServeMux() mux := http.NewServeMux()
@@ -117,16 +145,36 @@ func main() {
mux.HandleFunc("/api/httptriggers", s.handleList(httpTrigGVR)) mux.HandleFunc("/api/httptriggers", s.handleList(httpTrigGVR))
mux.HandleFunc("/api/timetriggers", s.handleList(timeTrigGVR)) mux.HandleFunc("/api/timetriggers", s.handleList(timeTrigGVR))
mux.HandleFunc("/console/api/environments", s.handleList(environmentGVR)) auth := func(h http.HandlerFunc) http.HandlerFunc {
mux.HandleFunc("/console/api/packages", s.handleList(packageGVR)) return func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/console/api/functions", s.handleFunctionsRoot) token := strings.TrimSpace(r.Header.Get("X-Auth-Token"))
mux.HandleFunc("/console/api/functions/", s.handleFunctionsAction) env := strings.TrimSpace(strings.ToLower(r.Header.Get("X-Auth-Env")))
mux.HandleFunc("/console/api/httptriggers", s.handleList(httpTrigGVR)) if _, ok := deckAPIs[env]; !ok {
mux.HandleFunc("/console/api/timetriggers", s.handleList(timeTrigGVR)) env = "test"
}
if token == "" {
writeJSONError(w, http.StatusUnauthorized, "unauthorized")
return
}
if err := s.validateDeckToken(token, env); err != nil {
writeJSONError(w, http.StatusUnauthorized, "unauthorized")
return
}
h(w, r)
}
}
mux.HandleFunc("/console/api/auth", s.handleAuth)
mux.HandleFunc("/console/api/environments", auth(s.handleList(environmentGVR)))
mux.HandleFunc("/console/api/packages", auth(s.handleList(packageGVR)))
mux.HandleFunc("/console/api/functions", auth(s.handleFunctionsRoot))
mux.HandleFunc("/console/api/functions/", auth(s.handleFunctionsAction))
mux.HandleFunc("/console/api/httptriggers", auth(s.handleList(httpTrigGVR)))
mux.HandleFunc("/console/api/timetriggers", auth(s.handleList(timeTrigGVR)))
httpServer := &http.Server{ httpServer := &http.Server{
Addr: ":" + port, Addr: ":" + port,
Handler: withCORS(logRequests(mux)), Handler: withSecurityHeaders(withCORS(logRequests(mux))),
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
} }
@@ -196,13 +244,38 @@ func (s *server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
} }
req.Name = strings.TrimSpace(req.Name) req.Name = strings.TrimSpace(req.Name)
req.Language = strings.TrimSpace(req.Language)
req.Environment = strings.TrimSpace(req.Environment) req.Environment = strings.TrimSpace(req.Environment)
req.Code = strings.TrimSpace(req.Code) req.Code = strings.TrimSpace(req.Code)
req.Entrypoint = strings.TrimSpace(req.Entrypoint) req.Entrypoint = strings.TrimSpace(req.Entrypoint)
req.Route = strings.TrimSpace(req.Route) req.Route = strings.TrimSpace(req.Route)
// Resolve language → environment (auto-create if needed)
if req.Language != "" {
langDef, ok := langEnvMap[req.Language]
if !ok {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("unsupported language: %q", req.Language))
return
}
envName := "console-" + req.Language + "-env"
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
_, err := s.dyn.Resource(environmentGVR).Namespace(s.ns).Get(ctx, envName, metav1.GetOptions{})
if apierrors.IsNotFound(err) {
env := s.buildLangEnvironment(envName, langDef)
if _, err := s.dyn.Resource(environmentGVR).Namespace(s.ns).Create(ctx, env, metav1.CreateOptions{}); err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create environment %q: %v", envName, err))
return
}
} else if err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("check environment: %v", err))
return
}
req.Environment = envName
}
if req.Name == "" || req.Environment == "" || req.Code == "" { if req.Name == "" || req.Environment == "" || req.Code == "" {
writeJSONError(w, http.StatusBadRequest, "name, environment and code are required") writeJSONError(w, http.StatusBadRequest, "name, environment/language and code are required")
return return
} }
if req.Entrypoint == "" { if req.Entrypoint == "" {
@@ -231,15 +304,30 @@ func (s *server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
methodValues = append(methodValues, method) methodValues = append(methodValues, method)
} }
literal := base64.StdEncoding.EncodeToString([]byte(req.Code)) // Build the package spec: Go uses source archive (builder), others use literal deployment
pkg := &unstructured.Unstructured{Object: map[string]any{ var pkgSpec map[string]any
"apiVersion": "fission.io/v1", if req.Language == "go" {
"kind": "Package", srcZip, err := s.buildGoSourceZip(req.Code)
"metadata": map[string]any{ if err != nil {
"name": pkgName, writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("build go source archive: %v", err))
"namespace": s.ns, return
}, }
"spec": map[string]any{ literal := base64.StdEncoding.EncodeToString(srcZip)
pkgSpec = map[string]any{
"source": map[string]any{
"type": "literal",
"literal": literal,
},
"deployment": map[string]any{},
"environment": map[string]any{
"name": req.Environment,
"namespace": s.ns,
},
"buildcommand": "build",
}
} else {
literal := base64.StdEncoding.EncodeToString([]byte(req.Code))
pkgSpec = map[string]any{
"deployment": map[string]any{ "deployment": map[string]any{
"type": "literal", "type": "literal",
"literal": literal, "literal": literal,
@@ -249,7 +337,17 @@ func (s *server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
"namespace": s.ns, "namespace": s.ns,
}, },
"source": map[string]any{}, "source": map[string]any{},
}
}
pkg := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Package",
"metadata": map[string]any{
"name": pkgName,
"namespace": s.ns,
}, },
"spec": pkgSpec,
}} }}
if _, err := s.dyn.Resource(packageGVR).Namespace(s.ns).Create(ctx, pkg, metav1.CreateOptions{}); err != nil { if _, err := s.dyn.Resource(packageGVR).Namespace(s.ns).Create(ctx, pkg, metav1.CreateOptions{}); err != nil {
@@ -319,6 +417,58 @@ func (s *server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
}) })
} }
func (s *server) buildLangEnvironment(name string, def langEnvDef) *unstructured.Unstructured {
spec := map[string]any{
"version": int64(3),
"runtime": map[string]any{
"image": def.Image,
},
"poolsize": int64(1),
}
if def.BuilderImage != "" {
spec["builder"] = map[string]any{
"image": def.BuilderImage,
"command": "build",
}
}
return &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Environment",
"metadata": map[string]any{
"name": name,
"namespace": s.ns,
},
"spec": spec,
}}
}
func (s *server) buildGoSourceZip(code string) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
fw, err := zw.Create("handler.go")
if err != nil {
return nil, err
}
if _, err := fw.Write([]byte(code)); err != nil {
return nil, err
}
goMod := "module github.com/user/fn\n\ngo 1.23\n"
fw2, err := zw.Create("go.mod")
if err != nil {
return nil, err
}
if _, err := fw2.Write([]byte(goMod)); err != nil {
return nil, err
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func (s *server) handleGetFunction(w http.ResponseWriter, r *http.Request, name string) { func (s *server) handleGetFunction(w http.ResponseWriter, r *http.Request, name string) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel() defer cancel()
@@ -341,12 +491,7 @@ func (s *server) handleGetFunction(w http.ResponseWriter, r *http.Request, name
if packageName != "" { if packageName != "" {
pkg, pkgErr := s.dyn.Resource(packageGVR).Namespace(s.ns).Get(ctx, packageName, metav1.GetOptions{}) pkg, pkgErr := s.dyn.Resource(packageGVR).Namespace(s.ns).Get(ctx, packageName, metav1.GetOptions{})
if pkgErr == nil { if pkgErr == nil {
literal, _, _ := unstructured.NestedString(pkg.Object, "spec", "deployment", "literal") code = s.extractPackageSourceCode(ctx, pkg)
if literal != "" {
if decodedCode, decErr := decodeLiteralToSource(literal); decErr == nil {
code = decodedCode
}
}
} }
} }
@@ -430,7 +575,23 @@ func (s *server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request
return return
} }
writeAnyJSON(w, http.StatusOK, map[string]any{"updated": true, "package": pkgName}) updatedPkg, err := s.dyn.Resource(packageGVR).Namespace(s.ns).Get(ctx, pkgName, metav1.GetOptions{})
if err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get updated package %q: %v", pkgName, err))
return
}
if err := unstructured.SetNestedField(fn.Object, updatedPkg.GetResourceVersion(), "spec", "package", "packageref", "resourceversion"); err != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set function package resourceversion: %v", err))
return
}
if _, err := s.dyn.Resource(functionGVR).Namespace(s.ns).Update(ctx, fn, metav1.UpdateOptions{}); err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update function %q package ref: %v", name, err))
return
}
writeAnyJSON(w, http.StatusOK, map[string]any{"updated": true, "package": pkgName, "package_resourceversion": updatedPkg.GetResourceVersion()})
} }
func (s *server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, name string) { func (s *server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, name string) {
@@ -443,7 +604,12 @@ func (s *server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, na
bodyBytes = []byte("{}") bodyBytes = []byte("{}")
} }
ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second) invokeTimeout := s.invokeTimeout
if invokeTimeout <= 0 {
invokeTimeout = 20 * time.Second
}
ctx, cancel := context.WithTimeout(r.Context(), invokeTimeout)
defer cancel() defer cancel()
invokeURL := fmt.Sprintf("%s/fission-function/v2/functions/%s", s.routerURL, name) invokeURL := fmt.Sprintf("%s/fission-function/v2/functions/%s", s.routerURL, name)
@@ -500,6 +666,15 @@ func (s *server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, na
resp, err := s.http.Do(req) resp, err := s.http.Do(req)
if err != nil { if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q timeout after %s: function specialization likely failed (for example, syntax error)", name, invokeTimeout))
return
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q timeout after %s: function specialization likely failed (for example, syntax error)", name, invokeTimeout))
return
}
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q: %v", name, err)) writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q: %v", name, err))
return return
} }
@@ -566,6 +741,63 @@ func (s *server) getRouterToken() string {
return s.cachedJWT return s.cachedJWT
} }
func (s *server) validateDeckToken(token, env string) error {
cacheKey := env + ":" + token
if v, ok := s.tokenCache.Load(cacheKey); ok {
if time.Now().Before(v.(time.Time)) {
return nil
}
s.tokenCache.Delete(cacheKey)
}
apiBase, ok := deckAPIs[env]
if !ok {
return fmt.Errorf("unknown env: %s", env)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiBase+"/index.cfm/instances", nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := s.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("invalid token")
}
s.tokenCache.Store(cacheKey, time.Now().Add(5*time.Minute))
return nil
}
func (s *server) handleAuth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var body struct {
Token string `json:"token"`
Env string `json:"env"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Token) == "" {
writeJSONError(w, http.StatusBadRequest, "token required")
return
}
env := strings.TrimSpace(strings.ToLower(body.Env))
if _, ok := deckAPIs[env]; !ok {
env = "test"
}
if err := s.validateDeckToken(body.Token, env); err != nil {
writeJSONError(w, http.StatusUnauthorized, "invalid token")
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "env": env})
}
func (s *server) handleDeleteFunction(w http.ResponseWriter, r *http.Request, name string) { func (s *server) handleDeleteFunction(w http.ResponseWriter, r *http.Request, name string) {
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel() defer cancel()
@@ -669,7 +901,7 @@ func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS") w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Auth-Token, X-Auth-Env")
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return return
@@ -678,6 +910,17 @@ func withCORS(next http.Handler) http.Handler {
}) })
} }
func withSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self' data:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests; block-all-mixed-content")
next.ServeHTTP(w, r)
})
}
func envDefault(key, fallback string) string { func envDefault(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" { if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v return v
@@ -685,6 +928,19 @@ func envDefault(key, fallback string) string {
return fallback return fallback
} }
func envDurationDefault(key string, fallback time.Duration) time.Duration {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return fallback
}
d, err := time.ParseDuration(raw)
if err != nil || d <= 0 {
log.Printf("invalid duration for %s=%q, using default %s", key, raw, fallback)
return fallback
}
return d
}
func normalizeMethods(in []string) []string { func normalizeMethods(in []string) []string {
if len(in) == 0 { if len(in) == 0 {
return []string{"GET"} return []string{"GET"}
@@ -705,12 +961,78 @@ func normalizeMethods(in []string) []string {
return out return out
} }
func (s *server) extractPackageSourceCode(ctx context.Context, pkg *unstructured.Unstructured) string {
literalPaths := [][]string{
{"spec", "source", "literal"},
{"spec", "deployment", "literal"},
}
for _, p := range literalPaths {
literal, found, _ := unstructured.NestedString(pkg.Object, p...)
if !found || strings.TrimSpace(literal) == "" {
continue
}
if decodedCode, decErr := decodeLiteralToSource(literal); decErr == nil && strings.TrimSpace(decodedCode) != "" {
return decodedCode
}
}
urlPaths := [][]string{
{"spec", "source", "url"},
{"spec", "deployment", "url"},
}
for _, p := range urlPaths {
urlValue, found, _ := unstructured.NestedString(pkg.Object, p...)
if !found || strings.TrimSpace(urlValue) == "" {
continue
}
archiveBytes, fetchErr := s.fetchPackageArchive(ctx, urlValue)
if fetchErr != nil {
continue
}
decodedCode, decErr := decodeArchiveBytesToSource(archiveBytes)
if decErr == nil && strings.TrimSpace(decodedCode) != "" {
return decodedCode
}
}
return ""
}
func (s *server) fetchPackageArchive(ctx context.Context, archiveURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, archiveURL, nil)
if err != nil {
return nil, err
}
resp, err := s.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("archive request failed: %s", resp.Status)
}
return io.ReadAll(resp.Body)
}
func decodeLiteralToSource(literal string) (string, error) { func decodeLiteralToSource(literal string) (string, error) {
decoded, err := base64.StdEncoding.DecodeString(literal) decoded, err := base64.StdEncoding.DecodeString(literal)
if err != nil { if err != nil {
return "", err return "", err
} }
return decodeArchiveBytesToSource(decoded)
}
func decodeArchiveBytesToSource(decoded []byte) (string, error) {
if len(decoded) == 0 {
return "", fmt.Errorf("empty payload")
}
if utf8.Valid(decoded) { if utf8.Valid(decoded) {
return string(decoded), nil return string(decoded), nil
} }
@@ -721,7 +1043,7 @@ func decodeLiteralToSource(literal string) (string, error) {
} }
} }
return string(decoded), nil return "", fmt.Errorf("payload does not contain utf-8 source")
} }
func decodeZipSource(zipBytes []byte) (string, error) { func decodeZipSource(zipBytes []byte) (string, error) {
@@ -730,7 +1052,7 @@ func decodeZipSource(zipBytes []byte) (string, error) {
return "", err return "", err
} }
preferred := []string{"main.py", "main.js", "main.go"} preferred := []string{"main.py", "main.js", "main.go", "handler.go", "handler.js", "handler.py"}
for _, name := range preferred { for _, name := range preferred {
for _, file := range reader.File { for _, file := range reader.File {
if strings.EqualFold(file.Name, name) { if strings.EqualFold(file.Name, name) {
+71
View File
@@ -142,6 +142,77 @@ func TestUpdateFunctionCode(t *testing.T) {
} }
} }
func TestGetFunctionUsesSourceLiteralWhenDeploymentLiteralMissing(t *testing.T) {
env := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Environment",
"metadata": map[string]any{"name": "go-acc", "namespace": "default"},
}}
s := newTestServer(env)
pkg := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Package",
"metadata": map[string]any{
"name": "fn-go-acc-pkg",
"namespace": "default",
},
"spec": map[string]any{
"source": map[string]any{
"literal": base64.StdEncoding.EncodeToString([]byte("package main\n\nfunc Handler() {}\n")),
},
"deployment": map[string]any{
"type": "url",
"url": "http://storagesvc.fission/v1/archive?id=dummy",
},
},
}}
fn := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Function",
"metadata": map[string]any{
"name": "fn-go-acc",
"namespace": "default",
},
"spec": map[string]any{
"environment": map[string]any{"name": "go-acc", "namespace": "default"},
"package": map[string]any{
"functionName": "Handler",
"packageref": map[string]any{
"name": "fn-go-acc-pkg",
"namespace": "default",
},
},
},
}}
if _, err := s.dyn.Resource(packageGVR).Namespace("default").Create(context.Background(), pkg, metav1.CreateOptions{}); err != nil {
t.Fatalf("create package: %v", err)
}
if _, err := s.dyn.Resource(functionGVR).Namespace("default").Create(context.Background(), fn, metav1.CreateOptions{}); err != nil {
t.Fatalf("create function: %v", err)
}
getReq := httptest.NewRequest(http.MethodGet, "/api/functions/fn-go-acc", nil)
getRec := httptest.NewRecorder()
s.handleGetFunction(getRec, getReq, "fn-go-acc")
if getRec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", getRec.Code, getRec.Body.String())
}
var out map[string]any
if err := json.Unmarshal(getRec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode get response: %v", err)
}
code, _ := out["code"].(string)
if !strings.Contains(code, "func Handler") {
t.Fatalf("expected source code from spec.source.literal, got %q", code)
}
}
func TestInvokeFunctionWithJWTAuth(t *testing.T) { func TestInvokeFunctionWithJWTAuth(t *testing.T) {
// Mock router: /auth/login returns JWT, /inv-fn returns hello // Mock router: /auth/login returns JWT, /inv-fn returns hello
var gotAuth string var gotAuth string
+243 -55
View File
@@ -3,7 +3,13 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fission Console</title> <title>NUBES Fission Console</title>
<link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAA4ZJREFUeJztm8+Lm0UYxz/fySZUoWIrVtbmXfyBP05Fa3e3rogsglJRK0p7E2svnrz6B3gQ8eRJFooXET14KKJ4FUQo3UQQ9CRC6W4WQW0VXZduTd7HQ9c22bzbTCY/ZpO8n0syb56Z+c437zPvhMzIzJhkXGwBsckNiC0gNrkBsQXEZsonSJLj4GNPgb2ACo9j9gDOvrSV6qkB6+sJ6WSJ5MIxUo4h5sDuI9U7tlZ57/+YTAN0zyOPUi+9jOxJxMOUj9wJOBBg117QvqGMwhNpcQ/J+rOkPIc0j+xeytyGsaWXa2+kvc312gxQMleHYgFtrQ9GYJmgmSfup3z15xuDNW/dGXOAjd680NgshlYdvcH2mfEwQIU0tOp4GNADuQGxBcRmPAyQgh/W42FAD+QGxBYQm/EwIF8HhJMbEFtAX3DreQqEkhsQW0BsxsMAlz8Gg8kNiC0gNuNhgJvK54BQcgNiC+gLhUlPgcuTbkAP5AbEFhCbcAOM4LyLitIW3eEGyP7qWUwMHFdaixNO1v4Av5pG8H/yfWfvesk7tq5GczHDAHnmtu727nTQbJYe8o51+qOl2BYgNv1a0iHvTgeNGq94x6buYnOx3YCU3/1asn06ePQZ744HiXTCO3a6/m1zsd0Ax/f+HTc+kU76598AUDL/LpjfjjWxYdXqRvOljDvAfezfO3dQvvCDtLjHu04f0czsaSx9y7uC8eP2S20GWO38Z+A7DwDwIOX135TMvylpKI9VJQv7lcx+hfEhurELsCPOfdDWVtZ5ASWzZ4GXArQ1QJfA/kTW6BhtLgX+7qL9IqTTmKa7GjgAumKry7dsv5q9Vfbfq29QLL1I9wulAtgB4ADmoy9kY4fodugAODuT2dpOJ0aUzH8E6asBXe0+zP5h7bvbzay+/aOdv+Ha8mnE5YEKGxqF17MGDzcxwMzqTBUWgc65vKuxT7cm9kx2TIHrATOzxzHOEpZ5sanYamXuZgEdJzlbqXyOmzrO6N0J56hVj3YK8prl7eK5L6g3DmFc6l3XEDCWbLWyYGYdf9h1TIGWYMmRHDlDaqcY0qKnO/Qr4oStLH/jXSPk4KSShf2ovoTZ80Db4iICv2D2ttWqS91WDDKgpYHy4adR8TWww8BdmN2KrOg5Zwoo+PV0">
<script>
if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
window.location.replace('https://' + window.location.host + window.location.pathname + window.location.search + window.location.hash);
}
</script>
<style> <style>
:root { :root {
--bg-page: #001120; --bg-page: #001120;
@@ -39,6 +45,45 @@
letter-spacing: .3px; letter-spacing: .3px;
} }
.brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand-mark {
width: 30px;
height: 30px;
border-radius: 8px;
background: linear-gradient(135deg, #009dff 0%, #0055d4 100%);
color: #fff;
display: grid;
place-items: center;
font-size: 14px;
font-weight: 800;
letter-spacing: .02em;
box-shadow: 0 6px 18px rgba(0, 125, 255, .35);
}
.brand-text {
display: flex;
flex-direction: column;
line-height: 1.08;
}
.brand-text .nubes {
font-size: 12px;
letter-spacing: .22em;
font-weight: 800;
color: #8bc7ff;
}
.brand-text .product {
font-size: 13px;
font-weight: 700;
letter-spacing: .04em;
}
.btn { .btn {
background: var(--accent); background: var(--accent);
color: #fff; color: #fff;
@@ -242,31 +287,63 @@
</style> </style>
</head> </head>
<body> <body>
<div id="login-overlay" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.85); z-index:999; align-items:center; justify-content:center; padding:16px;">
<div class="panel" style="width:min(440px,100%);">
<div class="brand" style="margin-bottom:24px;">
<div class="brand-mark">N</div>
<div class="brand-text">
<div class="nubes">NUBES</div>
<div class="product">FISSION CONSOLE</div>
</div>
</div>
<div class="row" style="flex-direction:column; gap:6px; margin-bottom:12px;">
<label style="font-size:12px; color:#999;">Стенд</label>
<select id="l-env" style="background:var(--bg-base); border:1px solid var(--border); color:var(--fg); padding:8px 10px; border-radius:6px;">
<option value="dev">Dev</option>
<option value="test" selected>Test</option>
<option value="prod">Prod</option>
</select>
</div>
<div class="row" style="flex-direction:column; gap:6px; margin-bottom:12px;">
<label style="font-size:12px; color:#999;">Токен</label>
<textarea id="l-token" rows="5" style="background:var(--bg-base); border:1px solid var(--border); color:var(--fg); padding:8px 10px; border-radius:6px; font-family:monospace; font-size:12px; resize:vertical; width:100%; box-sizing:border-box;" placeholder="Введите токен..."></textarea>
</div>
<div id="l-error" style="display:none; color:#ff6b6b; font-size:13px; margin-bottom:10px;"></div>
<button id="l-btn" class="btn" style="width:100%;" onclick="doLogin()">Войти</button>
</div>
</div>
<div class="navbar"> <div class="navbar">
<div class="title">sless / Fission Console</div> <div class="brand">
<div class="brand-mark">N</div>
<div class="brand-text">
<div class="nubes">NUBES</div>
<div class="product">FISSION CONSOLE</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="openCreate()">+ Create Function</button> <button class="btn" onclick="openCreate()">+ Создать функцию</button>
<button class="btn ghost" onclick="doLogout()" style="margin-left:8px;">Выход</button>
</div> </div>
</div> </div>
<div class="wrap"> <div class="wrap">
<div class="grid"> <div class="grid">
<div class="card"><div class="k">Environments</div><div id="env-count" class="v">-</div></div> <div class="card"><div class="k">Окружения</div><div id="env-count" class="v">-</div></div>
<div class="card"><div class="k">Packages</div><div id="pkg-count" class="v">-</div></div> <div class="card"><div class="k">Пакеты</div><div id="pkg-count" class="v">-</div></div>
<div class="card"><div class="k">Functions</div><div id="fn-count" class="v">-</div></div> <div class="card"><div class="k">Функции</div><div id="fn-count" class="v">-</div></div>
<div class="card"><div class="k">HTTP Triggers</div><div id="http-count" class="v">-</div></div> <div class="card"><div class="k">HTTP-триггеры</div><div id="http-count" class="v">-</div></div>
<div class="card"><div class="k">Time Triggers</div><div id="time-count" class="v">-</div></div> <div class="card"><div class="k">Тайм-триггеры</div><div id="time-count" class="v">-</div></div>
</div> </div>
<div class="box"> <div class="box">
<div class="toolbar"> <div class="toolbar">
<div style="font-weight:600;">Functions</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>
<table> <table>
<thead> <thead>
<tr><th>Name</th><th>Environment</th><th>Package</th><th>Route</th><th>Methods</th><th class="nowrap">Actions</th></tr> <tr><th>Имя</th><th>Окружение</th><th>Пакет</th><th>Маршрут</th><th>Методы</th><th class="nowrap">Действия</th></tr>
</thead> </thead>
<tbody id="fn-rows"></tbody> <tbody id="fn-rows"></tbody>
</table> </table>
@@ -277,15 +354,21 @@
<div id="create-modal" class="modal"> <div id="create-modal" class="modal">
<div class="panel"> <div class="panel">
<h3>Create Function</h3> <h3>Создать функцию</h3>
<div class="row"> <div class="row">
<div class="field"> <div class="field">
<label>Name</label> <label>Name</label>
<input id="c-name" placeholder="demo-fn"> <input id="c-name" placeholder="demo-fn">
</div> </div>
<div class="field"> <div class="field">
<label>Environment</label> <label>Language</label>
<select id="c-env"></select> <select id="c-lang" onchange="onLangChange()">
<option value="python">Python</option>
<option value="nodejs">Node.js</option>
<option value="php">PHP</option>
<option value="ruby">Ruby</option>
<option value="perl">Perl</option>
</select>
</div> </div>
<div class="field"> <div class="field">
<label>Entrypoint</label> <label>Entrypoint</label>
@@ -298,26 +381,27 @@
<input id="c-route" placeholder="/demo-fn"> <input id="c-route" placeholder="/demo-fn">
</div> </div>
<div class="field"> <div class="field">
<label>Methods (comma separated)</label> <label>Методы (через запятую)</label>
<input id="c-methods" value="GET"> <input id="c-methods" value="GET">
</div> </div>
</div> </div>
<div> <div>
<label>Code</label> <label>Код</label>
<textarea id="c-code">def main(ctx): <textarea id="c-code">def main(ctx):
return {"ok": True, "msg": "hello from fission console"} return {"ok": True, "msg": "hello from fission console"}
</textarea> </textarea>
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn ghost" onclick="closeCreate()">Cancel</button> <button class="btn ghost" onclick="closeCreate()">Отмена</button>
<button id="c-submit" class="btn" onclick="submitCreate()">Create</button> <button id="c-submit" class="btn" onclick="submitCreate()">Создать</button>
</div> </div>
</div> </div>
</div> </div>
<div id="edit-modal" class="modal"> <div id="edit-modal" class="modal">
<div class="panel"> <div class="panel">
<h3 id="e-title">Edit Code</h3> <h3 id="e-title">Редактирование кода</h3>
<div id="e-tf-warn" style="display:none;background:#553300;color:#ffa;padding:8px 12px;border-radius:6px;margin-bottom:10px;font-size:13px;">\u26a0\ufe0f Эта функция управляется Terraform. Изменения могут быть перезаписаны при следующем <code>terraform apply</code>.</div>
<div class="row"> <div class="row">
<div class="field"> <div class="field">
<label>Name</label> <label>Name</label>
@@ -333,26 +417,26 @@
</div> </div>
</div> </div>
<div> <div>
<label>Code</label> <label>Код</label>
<textarea id="e-code"></textarea> <textarea id="e-code"></textarea>
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn ghost" onclick="closeEdit()">Cancel</button> <button class="btn ghost" onclick="closeEdit()">Отмена</button>
<button id="e-submit" class="btn" onclick="submitEdit()">Save</button> <button id="e-submit" class="btn" onclick="submitEdit()">Сохранить</button>
</div> </div>
</div> </div>
</div> </div>
<div id="invoke-modal" class="modal"> <div id="invoke-modal" class="modal">
<div class="panel"> <div class="panel">
<h3 id="i-title">Invoke</h3> <h3 id="i-title">Вызов</h3>
<div> <div>
<label>JSON payload</label> <label>JSON тело запроса</label>
<textarea id="i-body">{"name":"world"}</textarea> <textarea id="i-body">{"name":"world"}</textarea>
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn ghost" onclick="closeInvoke()">Cancel</button> <button class="btn ghost" onclick="closeInvoke()">Отмена</button>
<button id="i-submit" class="btn" onclick="submitInvoke()">Invoke</button> <button id="i-submit" class="btn" onclick="submitInvoke()">Вызвать</button>
</div> </div>
<div style="margin-top:10px;"> <div style="margin-top:10px;">
<label>Response</label> <label>Response</label>
@@ -372,8 +456,16 @@
currentInvoke: null currentInvoke: null
}; };
function authHeaders() {
return {
'X-Auth-Token': localStorage.getItem('auth_token') || '',
'X-Auth-Env': localStorage.getItem('auth_env') || 'test'
};
}
async function getJSON(url) { async function getJSON(url) {
const r = await fetch(url); const r = await fetch(url, {headers: authHeaders()});
if (r.status === 401) { doLogout(); throw new Error('Сессия истекла'); }
if (!r.ok) { if (!r.ok) {
let msg = ''; let msg = '';
try { try {
@@ -390,9 +482,10 @@
async function requestJSON(url, method, body) { async function requestJSON(url, method, body) {
const r = await fetch(url, { const r = await fetch(url, {
method: method, method: method,
headers: { 'Content-Type': 'application/json' }, headers: Object.assign({'Content-Type': 'application/json'}, authHeaders()),
body: body ? JSON.stringify(body) : undefined body: body ? JSON.stringify(body) : undefined
}); });
if (r.status === 401) { doLogout(); throw new Error('Сессия истекла'); }
let data = {}; let data = {};
try { data = await r.json(); } catch (_) {} try { data = await r.json(); } catch (_) {}
if (!r.ok) { if (!r.ok) {
@@ -439,12 +532,45 @@
.replaceAll("'", '&#39;'); .replaceAll("'", '&#39;');
} }
const LANG_TEMPLATES = {
python: {
entrypoint: 'main.main',
code: 'def main():\n return "hello from fission"'
},
nodejs: {
entrypoint: 'handler',
code: 'module.exports = async function(context) {\n return {\n status: 200,\n body: "hello from fission"\n };\n}'
},
go: {
entrypoint: 'Handler',
code: 'package main\n\nimport (\n "fmt"\n "net/http"\n)\n\nfunc Handler(w http.ResponseWriter, r *http.Request) {\n fmt.Fprintf(w, "hello from fission")\n}'
},
php: {
entrypoint: 'main.php::handler',
code: '<?php\nfunction handler($context)\n{\n $response = $context["response"];\n $response->getBody()->write("hello from fission");\n}'
},
ruby: {
entrypoint: 'handler',
code: '# frozen_string_literal: true\n\ndef handler\n "hello from fission"\nend'
},
perl: {
entrypoint: 'handler',
code: 'sub {\n return "hello from fission";\n}'
}
};
function onLangChange() {
var lang = document.getElementById('c-lang').value;
var t = LANG_TEMPLATES[lang];
if (t) {
document.getElementById('c-entry').value = t.entrypoint;
document.getElementById('c-code').value = t.code;
}
}
function openCreate() { function openCreate() {
const envSel = document.getElementById('c-env'); document.getElementById('c-lang').value = 'python';
envSel.innerHTML = (S.envs || []).map(function (e) { onLangChange();
const n = (e.metadata && e.metadata.name) || '';
return '<option value="' + h(n) + '">' + h(n) + '</option>';
}).join('');
document.getElementById('create-modal').classList.add('open'); document.getElementById('create-modal').classList.add('open');
document.getElementById('c-name').focus(); document.getElementById('c-name').focus();
} }
@@ -459,12 +585,12 @@
try { try {
const name = document.getElementById('c-name').value.trim(); const name = document.getElementById('c-name').value.trim();
if (!name) throw new Error('name is required'); if (!name) throw new Error('name is required');
const env = document.getElementById('c-env').value.trim(); const lang = document.getElementById('c-lang').value.trim();
if (!env) throw new Error('environment is required'); if (!lang) throw new Error('language is required');
await requestJSON(API_BASE + '/functions', 'POST', { await requestJSON(API_BASE + '/functions', 'POST', {
name: name, name: name,
environment: env, language: lang,
entrypoint: document.getElementById('c-entry').value.trim(), entrypoint: document.getElementById('c-entry').value.trim(),
route: document.getElementById('c-route').value.trim(), route: document.getElementById('c-route').value.trim(),
methods: parseMethods(document.getElementById('c-methods').value), methods: parseMethods(document.getElementById('c-methods').value),
@@ -472,10 +598,10 @@
}); });
closeCreate(); closeCreate();
showStatus('Function created: ' + name, 'ok'); showStatus('\u0424\u0443\u043d\u043a\u0446\u0438\u044f \u0441\u043e\u0437\u0434\u0430\u043d\u0430: ' + name, 'ok');
await reloadAll(); await reloadAll();
} catch (e) { } catch (e) {
showStatus('Create failed: ' + e.message, 'err'); showStatus('\u041e\u0448\u0438\u0431\u043a\u0430 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u044f: ' + e.message, 'err');
} finally { } finally {
btn.disabled = false; btn.disabled = false;
} }
@@ -485,14 +611,19 @@
try { try {
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 = 'Edit Code: ' + name; document.getElementById('e-title').textContent = '\u0420\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435: ' + name;
document.getElementById('e-name').value = name; document.getElementById('e-name').value = name;
document.getElementById('e-env').value = fn.environment || ''; document.getElementById('e-env').value = fn.environment || '';
document.getElementById('e-entry').value = fn.entrypoint || ''; document.getElementById('e-entry').value = fn.entrypoint || '';
document.getElementById('e-code').value = fn.code || ''; document.getElementById('e-code').value = fn.code || '';
var warnEl = document.getElementById('e-tf-warn');
if (warnEl) {
var isTf = /^tf-/.test(name) || /go[-_]env/.test(fn.environment || '');
warnEl.style.display = isTf ? 'block' : 'none';
}
document.getElementById('edit-modal').classList.add('open'); document.getElementById('edit-modal').classList.add('open');
} catch (e) { } catch (e) {
showStatus('Load function failed: ' + e.message, 'err'); showStatus('\u041e\u0448\u0438\u0431\u043a\u0430 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0438 \u0444\u0443\u043d\u043a\u0446\u0438\u0438: ' + e.message, 'err');
} }
} }
@@ -511,9 +642,9 @@
code: document.getElementById('e-code').value code: document.getElementById('e-code').value
}); });
closeEdit(); closeEdit();
showStatus('Code updated: ' + name, 'ok'); showStatus('\u041a\u043e\u0434 \u043e\u0431\u043d\u043e\u0432\u043b\u0451\u043d: ' + name, 'ok');
} catch (e) { } catch (e) {
showStatus('Update failed: ' + e.message, 'err'); showStatus('\u041e\u0448\u0438\u0431\u043a\u0430 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f: ' + e.message, 'err');
} finally { } finally {
btn.disabled = false; btn.disabled = false;
} }
@@ -521,7 +652,7 @@
function openInvoke(name) { function openInvoke(name) {
S.currentInvoke = name; S.currentInvoke = name;
document.getElementById('i-title').textContent = 'Invoke: ' + name; document.getElementById('i-title').textContent = '\u0412\u044b\u0437\u043e\u0432: ' + name;
document.getElementById('i-resp').value = ''; document.getElementById('i-resp').value = '';
document.getElementById('invoke-modal').classList.add('open'); document.getElementById('invoke-modal').classList.add('open');
} }
@@ -542,20 +673,21 @@
const result = await requestJSON(API_BASE + '/functions/' + encodeURIComponent(S.currentInvoke) + '/invoke', 'POST', parsed); const result = await requestJSON(API_BASE + '/functions/' + encodeURIComponent(S.currentInvoke) + '/invoke', 'POST', parsed);
document.getElementById('i-resp').value = JSON.stringify(result, null, 2); document.getElementById('i-resp').value = JSON.stringify(result, null, 2);
} catch (e) { } catch (e) {
document.getElementById('i-resp').value = 'Invoke failed: ' + e.message; document.getElementById('i-resp').value = '\u041e\u0448\u0438\u0431\u043a\u0430 \u0432\u044b\u0437\u043e\u0432\u0430: ' + e.message;
} finally { } finally {
btn.disabled = false; btn.disabled = false;
} }
} }
async function removeFn(name) { async function removeFn(name) {
if (!confirm('Delete function ' + name + '?')) return; var tfWarn = (/^tf-/.test(name)) ? '\n\n\u26a0\ufe0f \u042d\u0442\u0430 \u0444\u0443\u043d\u043a\u0446\u0438\u044f \u0443\u043f\u0440\u0430\u0432\u043b\u044f\u0435\u0442\u0441\u044f Terraform. \u0423\u0434\u0430\u043b\u0435\u043d\u0438\u0435 \u043f\u0440\u0438\u0432\u0435\u0434\u0451\u0442 \u043a \u0440\u0430\u0441\u0441\u0438\u043d\u0445\u0440\u043e\u043d\u0438\u0437\u0430\u0446\u0438\u0438 state!' : '';
if (!confirm('\u0423\u0434\u0430\u043b\u0438\u0442\u044c \u0444\u0443\u043d\u043a\u0446\u0438\u044e ' + name + '?' + tfWarn)) return;
try { try {
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name), 'DELETE'); await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name), 'DELETE');
showStatus('Function deleted: ' + name, 'ok'); showStatus('\u0424\u0443\u043d\u043a\u0446\u0438\u044f \u0443\u0434\u0430\u043b\u0435\u043d\u0430: ' + name, 'ok');
await reloadAll(); await reloadAll();
} catch (e) { } catch (e) {
showStatus('Delete failed: ' + e.message, 'err'); showStatus('\u041e\u0448\u0438\u0431\u043a\u0430 \u0443\u0434\u0430\u043b\u0435\u043d\u0438\u044f: ' + e.message, 'err');
} }
} }
@@ -588,11 +720,13 @@
const route = (trig.spec && trig.spec.relativeurl) || '-'; const route = (trig.spec && trig.spec.relativeurl) || '-';
const methods = (trig.spec && trig.spec.methods) || []; const methods = (trig.spec && trig.spec.methods) || [];
const chips = methods.map(function (m) { return '<span class="chip">' + h(m) + '</span>'; }).join(''); const chips = methods.map(function (m) { return '<span class="chip">' + h(m) + '</span>'; }).join('');
const actions = [ var isGo = /go[-_]env/.test(env);
'<button class="btn ghost" onclick="openEdit(\'' + h(name) + '\')">Edit</button>', var isTf = /^tf-/.test(name);
'<button class="btn ghost" onclick="openInvoke(\'' + h(name) + '\')">Invoke</button>', var tfBadge = (isGo || isTf) ? '<span class="chip" style="background:#555;color:#ffa" title="Управляется Terraform. Изменения могут быть перезаписаны при terraform apply.">TF</span> ' : '';
'<button class="btn danger" onclick="removeFn(\'' + h(name) + '\')">Delete</button>' var actions = tfBadge +
].join(' '); '<button class="btn ghost" onclick="openEdit(\'' + h(name) + '\')">\u0420\u0435\u0434.</button> ' +
'<button class="btn ghost" onclick="openInvoke(\'' + h(name) + '\')">\u0412\u044b\u0437\u043e\u0432</button> ' +
'<button class="btn danger" onclick="removeFn(\'' + h(name) + '\')">\u0423\u0434\u0430\u043b\u0438\u0442\u044c</button>';
return '<tr>' + return '<tr>' +
'<td class="mono">' + h(name) + '</td>' + '<td class="mono">' + h(name) + '</td>' +
'<td>' + h(env) + '</td>' + '<td>' + h(env) + '</td>' +
@@ -603,17 +737,71 @@
'</tr>'; '</tr>';
}).join(''); }).join('');
document.getElementById('fn-rows').innerHTML = rows || '<tr><td colspan="3">No functions</td></tr>'; document.getElementById('fn-rows').innerHTML = rows || '<tr><td colspan="3">\u041d\u0435\u0442 \u0444\u0443\u043d\u043a\u0446\u0438\u0439</td></tr>';
if (!rows) { if (!rows) {
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="6">No functions</td></tr>'; document.getElementById('fn-rows').innerHTML = '<tr><td colspan="6">\u041d\u0435\u0442 \u0444\u0443\u043d\u043a\u0446\u0438\u0439</td></tr>';
} }
} catch (e) { } catch (e) {
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="6">Load error: ' + e.message + '</td></tr>'; document.getElementById('fn-rows').innerHTML = '<tr><td colspan="6">Load error: ' + e.message + '</td></tr>';
showStatus('Reload failed: ' + e.message, 'err'); showStatus('\u041e\u0448\u0438\u0431\u043a\u0430 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0438: ' + e.message, 'err');
} }
} }
reloadAll(); function showLoginOverlay() {
document.getElementById('login-overlay').style.display = 'flex';
}
function hideLoginOverlay() {
document.getElementById('login-overlay').style.display = 'none';
}
async function doLogin() {
var btn = document.getElementById('l-btn');
var errEl = document.getElementById('l-error');
var token = (document.getElementById('l-token').value || '').trim();
var env = document.getElementById('l-env').value;
if (!token) { errEl.textContent = 'Введите токен'; errEl.style.display = 'block'; return; }
btn.disabled = true;
errEl.style.display = 'none';
try {
const res = await fetch(API_BASE + '/auth', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({token: token, env: env})
});
if (!res.ok) {
const d = await res.json().catch(function() { return {}; });
throw new Error(d.error || 'Ошибка входа');
}
localStorage.setItem('auth_token', token);
localStorage.setItem('auth_env', env);
hideLoginOverlay();
reloadAll();
} catch(e) {
errEl.textContent = e.message;
errEl.style.display = 'block';
} finally {
btn.disabled = false;
}
}
function doLogout() {
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_env');
try { document.getElementById('l-token').value = ''; } catch(_) {}
showLoginOverlay();
}
function checkAuth() {
if (!localStorage.getItem('auth_token')) {
showLoginOverlay();
return;
}
hideLoginOverlay();
reloadAll();
}
checkAuth();
</script> </script>
</body> </body>
</html> </html>
+473
View File
@@ -0,0 +1,473 @@
# Аудит: Terraform Provider vs Fission Canonical CRD
**Дата:** 2026-06-03
**Ветка:** `feat/provider-audit`
**Предыдущая версия:** v0.2.4 (ветка `feat/console`)
## Методология
Сравнение производилось по трём источникам:
1. **Наш код**`/terraform/provider/internal/resources/*.go` и `/terraform/provider/internal/client/client.go`
2. **Fission CRD types.go**`github.com/fission/fission/pkg/apis/core/v1/types.go` (канонические Go-структуры)
3. **Реальные CRD объекты в кластере**`kubectl get` для environments/packages/functions/httptriggers (наши vs CLI-созданные)
---
## 1. ENVIRONMENT (fission_environment)
### 1.1 Что у нас
```go
// environmentResourceModel
ID, Name, Image, Version(default=3), PoolSize(default=3), Namespace, UID
```
`environmentToUnstructured` генерирует:
```json
{
"spec": {
"version": 3,
"runtime": { "image": "..." },
"poolsize": 3
}
}
```
### 1.2 Что делает Fission CLI (`fission env create`)
Fission CLI (из `environment/create.go`) создает полный `EnvironmentSpec`:
```json
{
"spec": {
"version": 3,
"runtime": {
"image": "ghcr.io/fission/python-env",
"container": { "name": "env-name", "resources": {} },
"podspec": { "containers": [{"name": "env-name", "resources": {}}] }
},
"builder": {
"image": "ghcr.io/fission/go-builder",
"command": "build",
"container": { "name": "builder", "resources": {} },
"podspec": { "containers": [{"name": "builder", "resources": {}}] }
},
"poolsize": 3,
"resources": {},
"imagepullsecret": "",
"keeparchive": false
}
}
```
### 1.3 Реальное сравнение в кластере
| Поле | Наш (tf-python-env) | CLI (python) | Вердикт |
|------|---------------------|--------------|---------|
| `spec.version` | 3 | 3 | ✅ OK |
| `spec.runtime.image` | ✅ | ✅ | ✅ OK |
| `spec.runtime.container` | ❌ отсутствует | `{name, resources}` | ⚠️ Fission заполняет defaults — не критично |
| `spec.runtime.podspec` | ❌ отсутствует | `{containers}` | ⚠️ Fission заполняет defaults — не критично |
| `spec.builder` | ❌ ОТСУТСТВУЕТ | `{image, command, container, podspec}` | 🔴 **КРИТИЧНО для Go** |
| `spec.poolsize` | 3 | 3 | ✅ OK |
| `spec.resources` | ❌ отсутствует | `{}` | ⚠️ Defaults — не критично |
| `spec.imagepullsecret` | ❌ | `""` | ⚠️ Можно добавить позже |
| `spec.keeparchive` | ❌ | `false` | ⚠️ Нужно для JVM — не критично сейчас |
### 1.4 Выводы по Environment
**Критичный баг:** Невозможно создать environment с builder (нет полей `builder_image`, `builder_command`). Это блокирует Go, любой язык с build step.
**Что добавить (приоритетно):**
- `builder_image` (string, optional) → `spec.builder.image`
- `builder_command` (string, optional) → `spec.builder.command`
**Что можно добавить позже:**
- `resources` (object) → `spec.resources`
- `imagepullsecret` (string) → `spec.imagepullsecret`
- `keeparchive` (bool) → `spec.keeparchive`
- `runtime_container_name` — Fission автозаполняет, мы не ставим, k8s принимает без него
**Что НЕ нужно (Fission автозаполняет):**
- `spec.runtime.container`, `spec.runtime.podspec` — заливаются defaults на стороне сервера
- `spec.builder.container`, `spec.builder.podspec` — аналогично
---
## 2. PACKAGE (fission_package)
### 2.1 Что у нас
```go
// packageResourceModel
ID, Name, Environment, SourceDir, CodePath, CodeHash, BuildCmd, Namespace, UID, BuildStatus, BuildLog
```
`packageToUnstructured` генерирует:
```json
{
"spec": {
"deployment": {
"type": "literal",
"literal": "base64..."
},
"environment": { "name": "...", "namespace": "..." },
"source": {}
}
}
```
### 2.2 Что делает Fission CLI
Для **deploy-only** (literal):
```json
{
"spec": {
"deployment": {
"type": "literal",
"literal": "base64...",
"checksum": {}
},
"environment": { "name": "...", "namespace": "..." },
"source": { "checksum": {} }
},
"status": {
"buildstatus": "none"
}
}
```
Для **source-with-builder** (Go, Node с build):
```json
{
"spec": {
"source": {
"type": "literal",
"literal": "base64-of-zip...",
"checksum": {}
},
"environment": { "name": "...", "namespace": "..." },
"buildcmd": "build"
},
"status": {
"buildstatus": "pending"
}
}
```
Для **large archives** (>256KB):
- Загрузка через StorageSvc `/v1/archive` (multipart POST)
- В CRD сохраняется `type: "url"`, `url: "http://storagesvc/v1/archive?id=..."`
### 2.3 Реальное сравнение в кластере
| Поле | Наш (tf-hello-pkg) | CLI (hello-*) | Вердикт |
|------|---------------------|---------------|---------|
| `spec.deployment.type` | `"literal"` | `"literal"` | ✅ OK |
| `spec.deployment.literal` | ✅ base64 | ✅ base64 | ✅ OK |
| `spec.deployment.checksum` | ❌ отсутствует | `{}` | ⚠️ K8s принимает без, но лучше добавить |
| `spec.environment` | ✅ | ✅ | ✅ OK |
| `spec.source` | `{}` (пустая map) | `{"checksum":{}}` | 🟡 **БАГ**: мы ставим пустой source — не мешает, но мусор |
| `spec.buildcmd` | ✅ (если задан) | ✅ | ✅ OK |
| `status.buildstatus` | `"none"` (от k8s default) | `"none"` | ✅ OK (k8s сам ставит) |
### 2.4 Что ОТСУТСТВУЕТ для builder pipeline (Go)
Для Go-функций нужен **source** package (не deployment):
1. Код упаковывается в zip
2. zip кодируется в base64 → `spec.source.literal` (если <256KB)
3. `spec.source.type` = `"literal"`
4. `spec.deployment` = пусто
5. `spec.buildcmd` = `"build"` (или пользовательская)
6. `status.buildstatus` = `"pending"` → builder собирает → `"succeeded"`/`"failed"`
7. После build: `spec.deployment` заполняется builder'ом (url на StorageSvc)
### 2.5 Выводы по Package
**Баг (некритичный):** Мы ВСЕГДА ставим `"source": {}` — пустой объект. Fission ставит `"source": {"checksum": {}}`. Оба варианта работают, но чистый вариант — не ставить source вообще если нет source archive.
**Что добавить (приоритетно):**
- **Режим source archive** — для Go и языков с build step. Нужно:
- Флаг/переключатель: deployment-only vs source-with-build
- Упаковка source_dir в zip → base64 → `spec.source.literal`
- Проверка размера <256KB (лимит ArchiveLiteralSizeLimit)
- Очистка `spec.deployment` при source mode
- `status.buildstatus` = `"pending"` на create
**Что можно добавить позже:**
- StorageSvc загрузка для >256KB архивов
- `spec.source.checksum`
- Поддержка `type: "url"` (для уже загруженных архивов)
---
## 3. FUNCTION (fission_function)
### 3.1 Что у нас
```go
// functionResourceModel
ID, Name, Environment, PackageName, Entrypoint, Namespace, UID
```
`functionToUnstructured` генерирует:
```json
{
"spec": {
"environment": { "name": "...", "namespace": "..." },
"InvokeStrategy": {
"ExecutionStrategy": { "ExecutorType": "poolmgr" },
"StrategyType": "execution"
},
"package": {
"packageref": { "name": "...", "namespace": "..." },
"functionName": "main.main"
}
}
}
```
### 3.2 Что делает Fission CLI (`fission fn create`)
```json
{
"spec": {
"environment": { "name": "...", "namespace": "..." },
"InvokeStrategy": {
"ExecutionStrategy": {
"ExecutorType": "poolmgr",
"MaxScale": 0,
"MinScale": 0,
"SpecializationTimeout": 120,
"TargetCPUPercent": 0
},
"StrategyType": "execution"
},
"package": {
"packageref": {
"name": "...",
"namespace": "...",
"resourceversion": "6000598"
},
"functionName": ""
},
"functionTimeout": 60,
"idletimeout": 120,
"concurrency": 500,
"requestsPerPod": 1,
"resources": {}
}
}
```
### 3.3 Реальное сравнение в кластере
| Поле | Наш (tf-hello-fn) | CLI (fn-js-acc) | Вердикт |
|------|---------------------|-----------------|---------|
| `spec.environment` | ✅ | ✅ | ✅ OK |
| `spec.InvokeStrategy.ExecutionStrategy.ExecutorType` | `"poolmgr"` | `"poolmgr"` | ✅ OK |
| `spec.InvokeStrategy.ExecutionStrategy.MaxScale` | ❌ отсутствует | `0` | ⚠️ Defaults работают, но лучше ставить |
| `spec.InvokeStrategy.ExecutionStrategy.MinScale` | ❌ | `0` | ⚠️ |
| `spec.InvokeStrategy.ExecutionStrategy.SpecializationTimeout` | ❌ | `120` | ⚠️ |
| `spec.InvokeStrategy.ExecutionStrategy.TargetCPUPercent` | ❌ | `0` | ⚠️ Не критично |
| `spec.InvokeStrategy.StrategyType` | `"execution"` | `"execution"` | ✅ OK |
| `spec.package.packageref.resourceversion` | ❌ отсутствует | ✅ | 🟡 CLI ставит для оптимизации, мы — нет |
| `spec.package.functionName` | ✅ `"main.main"` | `""` (или функция) | ✅ OK |
| `spec.functionTimeout` | ❌ | `60` | 🟡 Полезно для управления таймаутами |
| `spec.idletimeout` | ❌ | `120` | 🟡 Полезно для scale-to-zero |
| `spec.concurrency` | ❌ | `500` | ⚠️ |
| `spec.requestsPerPod` | ❌ | `1` | ⚠️ |
| `spec.resources` | ❌ | `{}` | ⚠️ |
### 3.4 Выводы по Function
**Критичных багов нет.** Наши функции работают, потому что k8s/Fission подставляет defaults. НО:
**Что добавить (приоритетно):**
- `executor_type` (string, optional, default="poolmgr") → для newdeploy/container strategies
- `function_timeout` (int, optional) → `spec.functionTimeout` — важно для долгих функций
- `idle_timeout` (int, optional) → `spec.idletimeout` — управление scale-to-zero
- `min_scale` / `max_scale` (int, optional) → ExecutionStrategy — для newdeploy
**Что можно добавить позже:**
- `concurrency` (int) → `spec.concurrency`
- `requests_per_pod` (int) → `spec.requestsPerPod`
- `specialization_timeout` (int) → `spec.InvokeStrategy.ExecutionStrategy.SpecializationTimeout`
- `resources` (object) → CPU/MEM limits
- `secrets`, `configmaps` (list) → volume mounts
---
## 4. HTTP TRIGGER (fission_http_trigger)
### 4.1 Что у нас
```go
// httpTriggerResourceModel
ID, Name, Function, URL, Methods, CreateIngress, Host, Namespace, UID
```
`httpTriggerToUnstructured` генерирует:
```json
{
"spec": {
"relativeurl": "/tf-hello",
"methods": ["GET"],
"functionref": { "type": "name", "name": "tf-hello-fn" },
"createingress": false
}
}
```
### 4.2 Что делает Fission CLI
```json
{
"spec": {
"relativeurl": "/hello",
"methods": ["GET"],
"functionref": {
"type": "name",
"name": "hello",
"functionweights": null
},
"createingress": false,
"host": "",
"ingressconfig": {
"annotations": null,
"host": "*",
"path": "/hello",
"tls": ""
},
"method": "",
"prefix": ""
}
}
```
### 4.3 Реальное сравнение в кластере
| Поле | Наш (tf-hello-route) | CLI (hello-route) | Вердикт |
|------|----------------------|-------------------|---------|
| `spec.relativeurl` | ✅ | ✅ | ✅ OK |
| `spec.methods` | ✅ | ✅ | ✅ OK |
| `spec.functionref.type` | `"name"` | `"name"` | ✅ OK |
| `spec.functionref.name` | ✅ | ✅ | ✅ OK |
| `spec.functionref.functionweights` | ❌ | `null` | ✅ Не нужно |
| `spec.createingress` | ✅ | ✅ | ✅ OK |
| `spec.host` | ❌ (если пусто) | `""` | ✅ Не критично |
| `spec.ingressconfig` | Частично (host) | Полный | ⚠️ IngressConfig неполный |
| `spec.method` | ❌ | `""` | ✅ Legacy, не нужно |
| `spec.prefix` | ❌ | `""` | ⚠️ Для prefix routing — добавить |
### 4.4 Выводы по HTTPTrigger
**Багов нет.** Работает корректно. Мелкие расхождения не влияют.
**Что можно добавить позже:**
- `prefix` (string) → `spec.prefix` — для prefix-based routing
- `keep_prefix` (bool) → `spec.keepPrefix`
- Полный `ingressconfig` (annotations, path, tls) — при `create_ingress=true`
- `function_weights` (map) → для canary deployments
---
## 5. CLIENT (client.go)
### 5.1 Оценка
**Код корректный.** Чистый CRUD через `dynamic.Interface`:
- 4 GVR определения (environments, packages, functions, httptriggers)
- CRUD для каждого: Create/Get/Update/Delete
- `IsNotFound()` для обработки 404
- `New()` строит config из kubeconfig + context
**Расхождений с Fission нет** — это наш собственный low-level клиент для работы с CRD.
---
## 6. VALIDATION (validation_helpers.go, entrypoint validation)
### 6.1 Оценка
- `ensureEnvironmentExists` — ✅ корректно (проверяет наличие env перед созданием pkg/fn)
- `ensurePackageExists` — ✅ корректно
- `validateEntrypointAgainstPackageSource` — ⚠️ Проверяет только Python `def funcname(`. Не проверяет:
- Node: `module.exports` или `export function`
- Go: plugin symbol
- PHP: `function handler(`
- Ruby: `def handler`
**Это допустимо** — избыточная валидация может мешать. Лучше валидировать только точно известные паттерны.
---
## 7. СВОДНАЯ ТАБЛИЦА ПРИОРИТЕТОВ
### 🔴 Критично (блокирует функционал)
| # | Ресурс | Проблема | Решение |
|---|--------|----------|---------|
| 1 | Environment | Нет builder support | Добавить `builder_image`, `builder_command` |
| 2 | Package | Нет source archive mode | Добавить zip-упаковку source_dir → `spec.source.literal` |
### 🟡 Важно (улучшает пользовательский опыт)
| # | Ресурс | Проблема | Решение |
|---|--------|----------|---------|
| 3 | Function | Захардкожен poolmgr | Добавить `executor_type` с optional default |
| 4 | Function | Нет пользовательских таймаутов | Добавить `function_timeout`, `idle_timeout` |
| 5 | Function | Нет min/max scale | Добавить `min_scale`, `max_scale` |
| 6 | Package | Пустой `source: {}` мусор | Убрать пустой source из payload |
### ⚪ Не критично (можно позже)
| # | Ресурс | Проблема |
|---|--------|----------|
| 7 | Environment | Нет resources, imagepullsecret, keeparchive |
| 8 | Function | Нет concurrency, requestsPerPod, resources, secrets, configmaps |
| 9 | HTTPTrigger | Нет prefix, keepPrefix, полного ingressconfig |
| 10 | Package | Нет StorageSvc загрузки (>256KB) |
| 11 | Package | Нет checksum |
---
## 8. ПЛАН РЕАЛИЗАЦИИ (предлагаемый)
### Этап 1: Builder support (Environment + Package)
**environment_resource.go:**
- Добавить поля `builder_image` и `builder_command` в модель и schema
- Добавить `spec.builder` в `environmentToUnstructured` (если builder_image задан)
- Обновить `unstructuredToEnvironmentModel` для чтения builder полей
**package_resource.go:**
- Добавить поле `deploy_type` (string: `"literal"` или `"source"`, default `"literal"`)
- При `deploy_type = "source"`: zip source_dir → base64 → `spec.source.literal`, `spec.deployment` пустой
- Убрать пустой `"source": {}` при deploy_type = "literal"
- Добавить base64 size check (<256KB) при literal mode
### Этап 2: Function tuning
**function_resource.go:**
- Добавить optional поля: `executor_type`, `function_timeout`, `idle_timeout`, `min_scale`, `max_scale`
- Обновить `functionToUnstructured` для заполнения ExecutionStrategy полностью
- Обновить `unstructuredToFunctionModel` для чтения новых полей
### Этап 3: Мелкие улучшения
- HTTPTrigger: prefix, keepPrefix
- Package: checksum
- Environment: resources, imagepullsecret
---
## 9. ВЫВОД
Наш провайдер **работает корректно для основного сценария**: Python/Node/PHP/Ruby/Perl literal deployment + poolmgr executor. Все критические поля (version, runtime.image, poolsize, deployment.literal, functionName, relativeurl, methods) генерируются правильно.
**Главные пробелы:**
1. Нет builder support → Go и любые compiled languages не работают через builder pipeline
2. Нет source archive → только deployment-only (literal из одного файла)
3. Function executor hardcoded to poolmgr → нет newdeploy/container strategy
4. Нет пользовательских таймаутов
Ни один из пробелов не является **ошибкой** в существующем коде — это **недостающий функционал**. То, что есть, соответствует канону Fission.
+399
View File
@@ -0,0 +1,399 @@
# План: поддержка всех языков Fission
**Дата:** 2026-04-15
**Исполнитель:** Sonnet / GPT 5.3 Codex (AI-агент)
**Цель:** Добавить рабочие функции на Go, Java, .NET, Ruby, Rust, PHP — и ПРОТЕСТИРОВАТЬ каждую
---
## Текущее состояние
| Язык | Статус | Проблемы |
|---|---|---|
| Python | ✅ Работает | — |
| Node.js | ✅ Работает | — |
| Go | ❌ Timeout | Runtime compilation > 20s specialization timeout |
| Java | ❌ Не развёрнуто | — |
| .NET | ❌ Не развёрнуто | — |
| Ruby | ❌ Не развёрнуто | — |
| Rust | ❌ Не развёрнуто | — |
| PHP | ❌ Не развёрнуто | — |
---
## КРИТИЧЕСКИЕ ПРАВИЛА (обязательно для агента)
1. **ВСЕ КОМАНДЫ — ТОЛЬКО ЧЕРЕЗ SSH:**
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
```
2. **Файлы редактировать можно локально**`/home/naeel/remote_dev/fission/` = `~/terra/fission/` на VM
3. **Terraform деплой:**
```bash
ssh ... 'cd ~/terra/fission/examples/FOLDER && TF_CLI_CONFIG_FILE=/tmp/terraformrc-fission terraform apply -auto-approve'
```
4. **Проверка функции (JWT обязателен):**
```bash
ssh ... '
PASSWORD=$(kubectl -n fission get secret router -o jsonpath={.data.password} | base64 -d)
TOKEN=$(curl -sk -X POST https://fission.kube5s.ru/auth/login -H "Content-Type: application/json" -d "{\"username\":\"admin\",\"password\":\"$PASSWORD\"}" | python3 -c "import sys,json; print(json.load(sys.stdin)[\"accesstoken\"])")
curl -sk -w "\nHTTP=%{http_code}\n" -H "Authorization: Bearer $TOKEN" https://fission.kube5s.ru/ENDPOINT
'
```
5. **НЕ ТРОГАТЬ существующие функции** — 15 шт., список в copilot-instructions.md
---
## Шаг 0: Проверить доступность образов (ОБЯЗАТЕЛЬНО ПЕРВЫМ)
Перед созданием — проверить что образы exist на ghcr.io:
```bash
ssh ... '
for lang in go java jvm dotnet dotnet20 ruby perl php binary; do
echo "--- $lang ---"
# Попробовать pull (dry-run)
docker pull ghcr.io/fission/${lang}-env:latest 2>&1 | head -3
echo
done
'
```
Если образ не найден — попробовать варианты:
- `ghcr.io/fission/go-env`
- `ghcr.io/fission/jvm-env` (Java = JVM в Fission!)
- `ghcr.io/fission/dotnet20-env` или `ghcr.io/fission/dotnet-env`
- `ghcr.io/fission/ruby-env`
- `ghcr.io/fission/php-env`
- `ghcr.io/fission/binary-env` (для предкомпилированных)
- `ghcr.io/fission/perl-env`
Если образ не существует — НЕ создавать функцию, пропустить язык.
---
## Шаг 1: Go (ПРОБЛЕМНЫЙ — требует особого подхода)
### Проблема
Go env компилирует код при specialization → timeout 20s → функция не работает.
### Решение: использовать `binary-env`
Go компилируется на VM → заливается как бинарник → binary-env его запускает.
**Или:** увеличить specialization timeout через InvokeStrategy:
```yaml
spec:
InvokeStrategy:
ExecutionStrategy:
ExecutorType: newdeploy # вместо poolmgr
MinScale: 1 # always running
MaxScale: 3
```
### Вариант A: pre-compiled + binary-env
```
examples/go-hello/
├── code/
│ └── main.go # исходник для справки
├── build.sh # скрипт для компиляции
└── main.tf
```
**build.sh:**
```bash
#!/bin/bash
cd code
CGO_ENABLED=0 GOOS=linux go build -o handler main.go
```
**main.tf** — использовать `binary-env` вместо `go-env`
### Вариант B: newdeploy executor
В Terraform manifests задать `executor_type = "newdeploy"` и `min_scale = 1`.
Проверить поддерживает ли наш провайдер эти аргументы:
```bash
ssh ... 'grep -r "executor\|newdeploy\|min_scale\|invoke_strategy" ~/terra/fission/internal/'
```
### Код функции (main.go):
```go
package main
import (
"net/http"
)
func Handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("Hello from Go in Fission"))
}
```
### Тест: HTTP 200, body содержит "Hello from Go"
---
## Шаг 2: Java (JVM)
### Образ: `ghcr.io/fission/jvm-env`
### Структура:
```
examples/java-hello/
├── code/
│ └── io/fission/Function.java
└── main.tf
```
### Код:
```java
package io.fission;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
public class Function implements io.fission.Function {
@Override
public ResponseEntity<?> call(RequestEntity req, io.fission.Context context) {
return ResponseEntity.ok("Hello from Java in Fission");
}
}
```
**ВАЖНО:** Проверить точный интерфейс JVM env. Может потребоваться другой формат:
```bash
ssh ... 'docker run --rm ghcr.io/fission/jvm-env:latest cat /app/README.md 2>/dev/null || echo "No README"'
```
### main.tf:
```hcl
resource "fission_environment" "jvm" {
name = "tf-jvm-hello-env"
image = "ghcr.io/fission/jvm-env"
version = 3
}
# ... стандартный pattern
```
### Тест: HTTP 200, body содержит "Hello from Java"
---
## Шаг 3: PHP
### Образ: `ghcr.io/fission/php-env`
### Структура:
```
examples/php-hello/
├── code/
│ └── hello.php
└── main.tf
```
### Код:
```php
<?php
return function() {
return "Hello from PHP in Fission";
};
```
**ВАЖНО:** entrypoint для PHP — имя файла без расширения (`hello`).
### Тест: HTTP 200, body содержит "Hello from PHP"
---
## Шаг 4: Ruby
### Образ: `ghcr.io/fission/ruby-env`
### Структура:
```
examples/ruby-hello/
├── code/
│ └── hello.rb
└── main.tf
```
### Код:
```ruby
def main
"Hello from Ruby in Fission"
end
```
### Тест: HTTP 200, body содержит "Hello from Ruby"
---
## Шаг 5: .NET (C#)
### Образ: `ghcr.io/fission/dotnet-env` или `ghcr.io/fission/dotnet20-env`
### Структура:
```
examples/dotnet-hello/
├── code/
│ └── FissionFunction.cs
└── main.tf
```
### Код:
```csharp
using System;
using Fission.DotNetCore.Api;
public class FissionFunction
{
public string Execute(FissionContext context)
{
return "Hello from .NET in Fission";
}
}
```
### Тест: HTTP 200, body содержит "Hello from .NET"
---
## Шаг 6: Rust
### ВЕРОЯТНО НЕ СУЩЕСТВУЕТ официально.
Проверить:
```bash
ssh ... 'docker pull ghcr.io/fission/rust-env:latest 2>&1'
```
Если нет — пропустить. Rust можно реализовать через `binary-env` (pre-compiled).
---
## Шаг 7: Perl (бонус)
### Образ: `ghcr.io/fission/perl-env`
```perl
sub main {
return "Hello from Perl in Fission";
}
```
---
## Порядок выполнения (ВАЖНО)
```
1. Проверить доступность ВСЕХ образов (docker pull) → составить список реальных
2. Для каждого доступного языка:
a. Создать examples/LANG-hello/code/... + main.tf
b. terraform apply
c. curl с JWT → проверить HTTP 200 + ожидаемый body
d. Если не работает — смотреть логи:
kubectl -n default get events --sort-by=".lastTimestamp" | tail -20
kubectl -n default get pods | grep poolmgr-LANG
kubectl -n default logs <pod> -c <container> --tail=30
e. Если timeout — попробовать newdeploy executor
f. Если всё равно не работает — удалить через terraform destroy
3. Обновить список в консоли (она автоматически видит новые функции)
4. Протестировать все через консоль (invoke)
5. Коммит + пуш + тег
```
---
## Критерий успеха
| Язык | Endpoint | Ожидаемый body | HTTP |
|---|---|---|---|
| Go | `/go-hello` | `Hello from Go in Fission` | 200 |
| Java | `/java-hello` | `Hello from Java in Fission` | 200 |
| PHP | `/php-hello` | `Hello from PHP in Fission` | 200 |
| Ruby | `/ruby-hello` | `Hello from Ruby in Fission` | 200 |
| .NET | `/dotnet-hello` | `Hello from .NET in Fission` | 200 |
| Perl | `/perl-hello` | `Hello from Perl in Fission` | 200 |
**Минимум:** 3 новых языка работают (Go + ещё 2)
**Идеал:** все 6
---
## Шаблон main.tf (копировать и менять)
```hcl
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "env" {
name = "tf-LANG-hello-env"
image = "ghcr.io/fission/LANG-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-LANG-hello-pkg"
environment = fission_environment.env.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-LANG-hello-fn"
environment = fission_environment.env.name
package_name = fission_package.pkg.name
entrypoint = "ENTRYPOINT"
}
resource "fission_http_trigger" "route" {
name = "tf-LANG-hello-route"
function = fission_function.fn.name
url = "/LANG-hello"
methods = ["GET"]
}
```
---
## Entrypoint для каждого языка
| Язык | Entrypoint | Файл |
|---|---|---|
| Python | `main.main` | `main.py` |
| Node.js | `module.exports` (пустой) | `server.js` |
| Go | `Handler` | `main.go` |
| Java | `io.fission.Function` | `Function.java` |
| PHP | `hello` | `hello.php` |
| Ruby | `hello.main` | `hello.rb` |
| .NET | `FissionFunction.Execute` | `FissionFunction.cs` |
| Perl | `hello.main` | `hello.pm` |
**⚠️ ВНИМАНИЕ:** Entrypoints могут отличаться от указанных! Проверять документацию каждого env:
```bash
ssh ... 'docker run --rm ghcr.io/fission/LANG-env:latest env 2>/dev/null | head -20'
```
---
## Откат при неудаче
Если язык не работает:
```bash
ssh ... 'cd ~/terra/fission/examples/LANG-hello && TF_CLI_CONFIG_FILE=/tmp/terraformrc-fission terraform destroy -auto-approve'
rm -rf examples/LANG-hello/
```
+204
View File
@@ -258,6 +258,30 @@
#### Правила репозитория #### Правила репозитория
Создан `.github/copilot-instructions.md` с правилами: Создан `.github/copilot-instructions.md` с правилами:
### Обновление этапа (UI invoke stability: JS)
- Проведена диагностика JS invoke через реальный UI-путь (`/console/api/functions/{name}/invoke`) и роутер/экзекьютор логи.
- Найдена корневая причина JS-таймаутов:
- для `node-env` используется `v2/specialize`
- при `functionName=main` runtime пытался загрузить `/userfunc/deployarchive/main`
- при plain literal `deployarchive` является файлом, не директорией
- после specialize без корректного контракта запросы зависали и упирались в router roundtripper timeout.
- Для `nodejs-acc` зафиксирован runtime image: `ghcr.io/fission/node-env:1.32.5`.
- Для JS-функций выровнен контракт runtime:
- `spec.package.functionName` выставлен в пустой entrypoint (`""`) для default export
- код приведен к формату ответа Node runtime: `return { status: 200, body: "..." }`
- для основных JS-маршрутов включены методы `GET` + `POST`.
- Подтверждена работоспособность через console invoke:
- `fn-js-acc` -> `status=200`, `response_raw=hello-js-ok`
- `fn-js-direct` -> `status=200`, `response_raw=hello-js-ok`
- тестовая матрица `jsm1..jsm4` -> `status=200`.
### Статус после фикса
- Python invoke: работает.
- JS invoke: работает по UI-пути и по прямому GET роутов.
- Go invoke (`fn-go-acc`): остается отдельной runtime-проблемой (вне JS-фикса).
- Маппинг путей (локально ~/remote_dev/ = ВМ ~/terra/) - Маппинг путей (локально ~/remote_dev/ = ВМ ~/terra/)
- Редактирование файлов — разрешено локально - Редактирование файлов — разрешено локально
- Команды — ИСКЛЮЧИТЕЛЬНО через SSH (VPN-конфликты) - Команды — ИСКЛЮЧИТЕЛЬНО через SSH (VPN-конфликты)
@@ -302,3 +326,183 @@
### Проверка ### Проверка
- `GET /console/api/functions/fn-go-acc` теперь возвращает читаемый Go-код, без `PK...` сигнатур. - `GET /console/api/functions/fn-go-acc` теперь возвращает читаемый Go-код, без `PK...` сигнатур.
## 2026-04-15 (дополнение) — Полный пользовательский прогон UI и invoke-fix
### Что проверено как пользовательский сценарий
- Прогнан массовый invoke через UI API (`POST /console/api/functions/{name}/invoke`) для всех функций в списке.
- Итого: `31` функций, из них `25` успешно отработали, `6` вернули 502/timeout (ожидаемо проблемные/сломанные кейсы).
- Проверен полный edit flow через UI API на рабочей функции:
- `GET function` -> `PUT /code` -> `POST /invoke` -> `PUT /code` (restore) -> `POST /invoke`.
### Найденный UI-баг и исправление
- Баг: после обновления кода через UI следующий invoke мог отдавать старую специализацию/кэш.
- Причина: обновлялся только `Package`, но не обновлялся `Function.spec.package.packageref.resourceversion`.
- Фикс в `console/main.go`:
- после `package update` читается новый `package.resourceVersion`
- выполняется update `Function` с новым `packageref.resourceversion`
- invoke сразу использует новую (или восстановленную) версию кода.
### Проверка фикса
- После update через UI invoke возвращает новый ответ.
- После restore через UI invoke возвращает исходный ответ (без зависания старого кэша).
### Статус `fn-go-acc`
- `fn-go-acc` продолжает падать не из-за UI, а из-за runtime specialization на стороне Fission.
- Подтверждено логами `router/executor`: `GetServiceForFunction ... context canceled` и постоянными readiness-fail у poolmgr pod-ов `go-acc`.
## 2026-04-15 (дополнение) — Финальный фикс `fn-go-acc`
### Симптом и root cause
- `fn-go-acc` стабильно timeout'ился на invoke.
- Изначальный пакет содержал `main.go` как `deployment.literal`, из-за чего go-runtime пытался грузить текст как plugin:
- `plugin.Open("/userfunc/deployarchive/main.go"): invalid ELF header`.
### Что сделано
- Пересобран deploy-артефакт как Go plugin (`main.so`) и упакован в `deploy.zip`.
- Важно: сборка выполнена в том же образе, что у Fission environment builder:
- `ghcr.io/fission/go-builder` (Go 1.25.6), чтобы избежать несовместимости plugin ABI.
- Функция обновлена через Fission CLI:
- `fission function update -n default --name fn-go-acc --env go-acc --entrypoint Handler --deployarchive /tmp/fn-go-acc-fix2/deploy.zip -f`.
### Результат проверки
- `/go-acc` через router с JWT: `hello-go-ok`, `HTTP 200`.
- Executor логи: specialization проходит успешно (`specialized pod`, `added function service`), без `invalid ELF`.
### Контрольный smoke после фикса
- `/auto/ok` -> `HTTP 200`
- `/js-acc` -> `HTTP 200`
- `/js-direct` -> `HTTP 200`
- `/go-acc` -> `HTTP 200`
## 2026-04-15 (дополнение) — Edit Code для `fn-go-acc` снова показывает исходник
### Проблема
- В `Edit Code` для `fn-go-acc` поле `code` было пустым.
- Причина: после `--deployarchive` пакет `pkg-go-acc` перешел на `spec.deployment.type=url`, а в console backend чтение кода шло только из `spec.deployment.literal`.
### Исправление
- В `console/main.go` добавлен fallback-поиск исходника:
- `spec.source.literal`
- `spec.deployment.literal`
- `spec.source.url`
- `spec.deployment.url`
- Добавлена загрузка архива по URL и извлечение исходника из zip (если есть текстовые файлы).
- Добавлен unit-тест `TestGetFunctionUsesSourceLiteralWhenDeploymentLiteralMissing`.
- Собран и выкачен образ `naeel/fission-console:v0.2.6`, деплой обновлен.
### Дополнительно по данным в кластере
- Для `fn-go-acc` обновлен `sourcearchive` (`main.go`), чтобы `pkg-go-acc.spec.source.literal` содержал исходник и был доступен в Edit Code.
### Проверка
- `GET /console/api/functions/fn-go-acc` возвращает непустой `code` (Go source).
- `/go-acc` продолжает отвечать `hello-go-ok`, `HTTP 200`.
## 2026-04-15 (дополнение) — TLS в console, бренд NUBES, проверка `tf-neg-syntax-fn`
### TLS: фактический статус
- `http://fission.kube5s.ru/console/` -> `308 Permanent Redirect` на `https://...`
- `https://fission.kube5s.ru/console/` -> `200`
- Ingress `fission-console` настроен с:
- `nginx.ingress.kubernetes.io/force-ssl-redirect: "true"`
- `tls.secretName: fission-tls`
- `cert-manager.io/cluster-issuer: letsencrypt-prod`
### UI оформление (NUBES)
- В `console/ui/index.html` добавлены:
- favicon (inline SVG)
- брендинг в navbar: mark + wordmark `NUBES` / `FISSION CONSOLE`
- обновлен `<title>` на `NUBES Fission Console`
- Выкат: `naeel/fission-console:v0.2.9`.
### Invoke `tf-neg-syntax-fn` — проверка
- Подтверждено, что у функции синтаксически невалидный код (`def main(: ...`).
- Console invoke теперь возвращает fail-fast ошибку (не «молчание»):
- `{"error":"invoke \"tf-neg-syntax-fn\" timeout after 20s: function specialization likely failed (for example, syntax error)"}`
- Причина на кластере: specialization падает в runtime с `500`, executor делает ретраи.
## 2026-04-15 (дополнение) — Лечение "красного" HTTPS
### Root cause
- На одном host `fission.kube5s.ru` было два ingress:
- `fission-console` с TLS
- `fission-router` без TLS
- Из-за смешанной host-конфигурации TLS мог работать нестабильно/давать красный индикатор в браузере.
### Fix
- Пропатчен `fission/fission-router`:
- добавлен `spec.tls` с `secretName: fission-tls`
- добавлена аннотация `nginx.ingress.kubernetes.io/force-ssl-redirect: "true"`
### Проверка
- `http://fission.kube5s.ru/neg/syntax` -> `308` redirect на HTTPS
- Сертификат endpoint: Let's Encrypt R13, CN/SAN `fission.kube5s.ru`, валиден
- `https://fission.kube5s.ru/console/` -> `HTTP 200`
## 2026-04-15 (дополнение) — Строгая очистка: оставлены только рабочие и с видимым кодом
### Требование
- Убрать всё лишнее и оставить только функции, которые одновременно:
- имеют непустой `code` в `GET /console/api/functions/{name}`
- успешно проходят `POST /console/api/functions/{name}/invoke` со `status=200`
### Что сделано
- Выполнена финальная очистка функций по whitelist.
- После очистки дополнительно проверены все оставшиеся функции через Console API (code + invoke).
- Исправлена ошибка в служебном cleanup-скрипте (битый jsonpath с `{n}`), из-за которой падала пост-обработка orphan-ресурсов.
### Итоговый набор
- `auth-test2`
- `fn-js-direct`
- `fn-js-acc`
- `hello`
- `tf-auto-ok-fn`
- `tf-stress-fast-fn`
### Финальное состояние кластера
- `functions=6`
- `httptriggers=6`
- `packages=6`
### Финальная валидация
- Для каждой из 6 функций: `code=OK`, `invoke=OK`.
## 2026-04-15 (дополнение) — Наращивание до 15 функций + исправление фавикона в live UI
### Запрос
- Увеличить набор до 15 функций, включая error-кейсы.
- Прогнать тесты и чинить код/манифесты при расхождении с ожиданием.
- Исправить favicon в реальном UI (а не только в репозитории).
### Что сделано по функциям
- Подняты функции из `examples`: `hello-python`, `deep-recursion`, `destroy-test`, `frequent-update`, `orphan-test`, `multi-env-1/2/3`.
- Добавлен негативный кейс `tf-neg-syntax-fn` с route `/neg/syntax`.
- Доведено до ровно `functions=15`, `httptriggers=15`.
### Фиксы по коду/манифестам (по результатам тестов)
- `examples/multi-env-1/main.tf`
- исправлен `source_dir` с `"$\{path.module\}/code"` на `"${path.module}/code"`.
- заменен image env с `ghcr.io/fission/python-env:v1.20.0` на `ghcr.io/fission/python-env`.
- `examples/multi-env-2/main.tf`
- аналогичные исправления `source_dir` и image.
- `examples/multi-env-3/main.tf`
- аналогичные исправления `source_dir` и image.
### Тест-матрица (router + JWT)
- Итог: `PASS 15 / FAIL 0`.
- Успешные (`HTTP 200`):
- `/auth-test2`, `/js-acc`, `/js-direct`, `/hello`, `/auto/ok`, `/destroy-test`, `/freq-update`, `/tf-hello`, `/multi-env-1`, `/multi-env-2`, `/multi-env-3`, `/orphan-test`, `/stress/fast`.
- Ожидаемые error-кейсы:
- `/deep-recursion` -> timeout (`curl rc=28`, `HTTP 000`)
- `/neg/syntax` -> timeout (`curl rc=28`, `HTTP 000`)
- Доп. проверка через Console invoke:
- `tf-multi-env-1-fn` -> `status=200`
- `tf-hello-fn` -> `status=200`
- `tf-neg-syntax-fn` и `tf-deep-recursion-fn` -> ожидаемая fail-fast ошибка invoke timeout.
### Фавикон (live)
- Причина «старого фавикона»: в кластере работал старый образ `naeel/fission-console:v0.3.0`.
- Собран и выкачен новый образ: `naeel/fission-console:v0.3.1`.
- Deployment обновлен и успешно прокатан.
- Проверено в live HTML: отдается
- `<link rel="icon" type="image/png" href="https://nubes.ru/themes/custom/nubes_2025/favicon.png">`.
+20
View File
@@ -0,0 +1,20 @@
# Тест auth v0.5.0 — 2026-04-19
## Что проверено
| Тест | Ожидание | Результат |
|------|----------|-----------|
| GET /console/ | 200 | ✅ 200 |
| POST /console/api/auth {token: "badtoken"} | {"error":"invalid token"} | ✅ |
| GET /console/api/functions без токена | 401 | ✅ 401 |
| Логин с реальным YC IAM токеном | {"ok":true,"env":"test"} | ⏳ не проверено — нет токена |
## Что не проверено
- Полный flow: логин → появление UI → CRUD функций
- Logout → блокировка доступа
- Проверка `env` (dev/prod)
## Итог
Защита работает: без токена — 401, плохой токен — ошибка. Полный flow нужно проверить вручную в браузере после получения YC IAM токена.
+271
View File
@@ -147,3 +147,274 @@ User → POST /console/api/functions/NAME/invoke
Причина: VPN может быть активен локально, вызывая сбои при прямом выполнении команд. Причина: VPN может быть активен локально, вызывая сбои при прямом выполнении команд.
Монтирование sshfs делает локальное редактирование файлов эквивалентным редактированию на ВМ. Монтирование sshfs делает локальное редактирование файлов эквивалентным редактированию на ВМ.
---
## Сессия: JS invoke timeouts в UI (root cause + fix)
### Симптом
- Все JS invoke через UI (`/console/api/functions/{name}/invoke`) падали по timeout.
- В router логах: `roundtripper timeout (60s)`/`error sending request to function`.
- В executor логах specialization для node окружения могла проходить, но ответ функции не возвращался.
### Ключевые находки
1. Node runtime работал через `POST /v2/specialize`.
2. При `functionName=main` runtime строил путь загрузки как:
- `/userfunc/deployarchive/main`
3. В реальном поде `/userfunc/deployarchive` был **файлом** (literal payload), а не директорией.
4. После перевода на пустой entrypoint (`functionName=""`) specialization стала успешной (`202`, `user code loaded`),
но запросы всё равно зависали.
5. Прямая проверка контейнера показала второй контрактный нюанс node runtime:
- пользовательская функция должна возвращать объект формата
`{ status, body, headers? }`
- возврат простой строки приводил к зависанию ответа (callback не отправлял HTTP response).
### Принятые изменения
- `nodejs-acc` закреплен на `ghcr.io/fission/node-env:1.32.5`.
- Для JS функций (`fn-js-acc`, `fn-js-direct`, тестовые `jsm1..jsm4`):
- выставлен пустой entrypoint (`functionName=""`)
- код приведен к контракту Node runtime:
- `module.exports = async function(context) { return { status: 200, body: "..." }; }`
- Для основных JS trigger включены методы `GET` и `POST`.
### Проверка
- UI invoke:
- `fn-js-acc` -> `status=200`, `response_raw=hello-js-ok`
- `fn-js-direct` -> `status=200`, `response_raw=hello-js-ok`
- Прямые GET через роутер:
- `/js-acc` -> 200
- `/js-direct` -> 200
- Тестовые `jsm1..jsm4` после унификации кода -> 200 через UI invoke.
### Вывод
Проблема была не в UI-рендеринге и не в одном конкретном trigger, а в несовпадении контракта Node runtime:
- entrypoint/path specialization
- shape возвращаемого значения функции.
---
## Сессия: Финальный fix `fn-go-acc` (Go runtime specialization)
### Симптом
- `fn-go-acc` выдавал timeout на invoke через console/router.
### Диагностика
1. На исходной конфигурации в executor логах:
- `plugin.Open("/userfunc/deployarchive/main.go"): invalid ELF header`.
2. Это означало, что runtime ожидал plugin-артефакт, но в package deployment лежал исходник `main.go`.
3. После первой попытки собрать plugin локальным `go1.26.1` ошибка `invalid ELF` ушла, но появился новый фейл specialization:
- `Post "http://127.0.0.1:8888/v2/specialize": EOF`.
4. Вывод: plugin собран несовместимой версией Go относительно runtime.
### Решение
- Сборка plugin выполнена в `ghcr.io/fission/go-builder` (Go 1.25.6), то есть тем же toolchain, что у Fission environment builder:
- `GO111MODULE=off /usr/local/go/bin/go build -buildmode=plugin -o main.so ./main.go`
- упаковка `main.so` в `deploy.zip`.
- Обновление функции через CLI:
- `fission function update -n default --name fn-go-acc --env go-acc --entrypoint Handler --deployarchive /tmp/fn-go-acc-fix2/deploy.zip -f`
### Проверка
- `/go-acc` через router с JWT: `hello-go-ok`, `HTTP 200`.
- В executor логах specialization успешен:
- `specialized pod`
- `added function service`
- без `invalid ELF` и без `EOF`.
### Контрольная матрица маршрутов
- `/auto/ok` -> 200
- `/js-acc` -> 200
- `/js-direct` -> 200
- `/go-acc` -> 200
### Практический вывод
Для go-env в этом кластере критично соблюдать контракт runtime:
1. deploy-пакет должен содержать именно plugin (`.so`), а не исходник.
2. plugin должен быть собран совместимой версией Go (через `ghcr.io/fission/go-builder`).
---
## Сессия: `Edit Code` для `fn-go-acc` пустой
### Симптом
- В UI (`GET /console/api/functions/fn-go-acc`) поле `code` было пустым.
### Почему так произошло
- После фикса runtime функция была переведена на deploy-архив plugin (`main.so`).
- Package имел вид:
- `spec.deployment.type=url`
- `spec.deployment.url=...`
- без `spec.deployment.literal`
- Backend в `handleGetFunction` извлекал код только из `spec.deployment.literal`, поэтому для URL-пакета отдавал пустую строку.
### Что изменено
- В backend добавлен `extractPackageSourceCode(...)` с fallback-приоритетом:
1. `spec.source.literal`
2. `spec.deployment.literal`
3. `spec.source.url`
4. `spec.deployment.url`
- Добавлена загрузка URL-архива через `fetchPackageArchive(...)` и декодирование через `decodeArchiveBytesToSource(...)`.
- Обновлен unit-тестами сценарий, где `deployment.literal` отсутствует, но есть `source.literal`.
### Операционные шаги
- Собран и задеплоен `naeel/fission-console:v0.2.6`.
- Для текущей `fn-go-acc` дополнительно обновлен `sourcearchive` (`main.go`), чтобы исходник гарантированно отображался в Edit Code.
### Результат
- `GET /console/api/functions/fn-go-acc` теперь возвращает непустой Go source в `code`.
- Вызов `/go-acc` остается рабочим (`hello-go-ok`, HTTP 200).
---
## Сессия: Проверка TLS + branding NUBES + верификация `tf-neg-syntax-fn`
### TLS
- Проверен доступ к console:
- `http://.../console/` -> `308` redirect на HTTPS
- `https://.../console/` -> `200`
- Ingress конфиг содержит корректный TLS блок и принудительный SSL redirect.
- Вывод: endpoint защищен TLS, «незащищенного HTTP-доступа» нет.
### Брендинг NUBES
- В `console/ui/index.html` добавлены:
- `<title>NUBES Fission Console</title>`
- favicon (inline SVG)
- navbar brand block с `NUBES` / `FISSION CONSOLE`.
- Обновлен образ до `naeel/fission-console:v0.2.9`.
### `tf-neg-syntax-fn` — почему timeout вместо прямого SyntaxError
- Функция содержит заведомо битый Python (`def main(:`), confirmed через `GET /console/api/functions/tf-neg-syntax-fn`.
- На runtime это роняет specialization (`500`), после чего executor ретраит.
- Поэтому router/console не получает «чистый traceback» сразу из runtime API и видит timeout/ошибку specialization.
- Для UX введен fail-fast и явная ошибка в console invoke:
- `timeout after 20s: function specialization likely failed (for example, syntax error)`.
---
## Сессия: "Не защищено красным" — экстренное лечение
### Наблюдение
- Сертификат endpoint сам по себе валидный (Let's Encrypt, CN/SAN = `fission.kube5s.ru`).
- Но на том же host были два ingress с разной TLS-конфигурацией:
- `fission-console` с TLS
- `fission-router` без TLS
### Действие
- Патч `fission-router`:
- добавлен `spec.tls` с `secretName: fission-tls`
- добавлен `force-ssl-redirect=true`
### Результат
- HTTP на роутер-пути дает 308 -> HTTPS
- HTTPS на console стабильно 200
- Конфигурация host выровнена: теперь и console, и router на одном сертификате.
---
## Сессия: Строгая чистка до "только точно рабочие и видимые"
### Запрос
- Оставить только функции, которые в UI/Console:
- показывают исходник (`code` не пустой)
- реально вызываются (`invoke.status == 200`)
### Что произошло
- Выполнен массовый cleanup с keep-list.
- По логу cleanup выявилась ошибка jsonpath в пост-обработке orphan-пакетов:
- использовалось `{n}` вместо `{"\\n"}`
- это ломало шаг удаления orphan-объектов после основного удаления.
- Основное удаление функций при этом сработало корректно.
### Проверка после cleanup
- Текущее состояние:
- functions: 6
- httptriggers: 6
- packages: 6
- Остались только:
- `auth-test2`
- `fn-js-direct`
- `fn-js-acc`
- `hello`
- `tf-auto-ok-fn`
- `tf-stress-fast-fn`
- Для каждой функции через Console API подтверждено:
- `code=OK`
- `invoke=OK`
### Вывод
- Финальный набор теперь соответствует строгому критерию "однозначно работает и показывается".
---
## Сессия: Возврат к 15 функциям + проверка ожиданий + live favicon
### Наблюдение в начале
- После строгой чистки в кластере оставалось 6 функций.
- В live UI favicon оставался старым, хотя в репозитории правка уже была.
- Проверка deployment показала: работал старый image `naeel/fission-console:v0.3.0`.
### Расширение набора до 15
- Добавлены функции из примеров:
- `tf-hello-fn`, `tf-deep-recursion-fn`, `tf-destroy-test-fn`, `tf-freq-update-fn`, `tf-orphan-fn`
- `tf-multi-env-1-fn`, `tf-multi-env-2-fn`, `tf-multi-env-3-fn`
- Добавлен негативный кейс:
- `tf-neg-syntax-fn` + route `/neg/syntax`
- Итоговый размер:
- functions = 15
- httptriggers = 15
### Что сломалось и как починено
1. `multi-env-1/2/3` не применялись:
- ошибка Terraform: `Invalid escape sequence` из-за `"$\{path.module\}/code"`.
- фикс: заменено на `"${path.module}/code"` во всех трех `main.tf`.
2. После применения `multi-env-1/2/3` маршруты таймаутили (`rc=28`):
- причина: image env `ghcr.io/fission/python-env:v1.20.0`.
- фикс: переключено на `ghcr.io/fission/python-env`, re-apply всех трех модулей.
- результат: `/multi-env-1`, `/multi-env-2`, `/multi-env-3` -> `HTTP 200`.
### Финальная тест-матрица
- Router tests с JWT: `PASS=15, FAIL=0`.
- Успешные 200: базовые + multi-env + tf-hello + stress-fast.
- Ожидаемые ошибки:
- `/deep-recursion` -> timeout (`HTTP 000`, `rc=28`)
- `/neg/syntax` -> timeout (`HTTP 000`, `rc=28`)
- Console invoke подтверждает:
- positive функции -> `status=200`
- negative функции -> fail-fast timeout error.
### Favicon в live
- Собран и выкачен `naeel/fission-console:v0.3.1`.
- `deployment/fission-console` обновлен и rollout успешен.
- Проверка `https://fission.kube5s.ru/console/` показывает нужный favicon URL:
- `https://nubes.ru/themes/custom/nubes_2025/favicon.png`.
+59
View File
@@ -0,0 +1,59 @@
# Thinking Log — Аудит провайдера, 2026-06-03
## Задача
Тщательное сравнение нашего Terraform provider для Fission с каноническим поведением Fission CLI и CRD types.
## Что было сделано
### 1. Чтение нашего кода
Прочитаны все 10 .go файлов (~1500 строк):
- `environment_resource.go` (136 строк)
- `package_resource.go` (460 строк)
- `function_resource.go` (350 строк)
- `http_trigger_resource.go` (310 строк)
- `client.go` (295 строк)
- `validation_helpers.go`, `import_helpers.go`
- 3 тест-файла
### 2. Чтение канонических исходников Fission
- `pkg/apis/core/v1/types.go` — все CRD Go-структуры
- `pkg/apis/core/v1/const.go` — константы (ArchiveLiteralSizeLimit=256KB, BuildStatus*, ExecutorType*)
- CLI: `environment/create.go`, `package/create.go`, `package/util/util.go`
- StorageSvc: `storagesvc/client/client.go`
### 3. Дамп реальных CRD из кластера
Через kubectl получены ВСЕ объекты всех 4 типов из кластера:
- 20+ environments (наши tf-* и CLI-созданные)
- 15+ functions (наши tf-* и CLI-созданные)
- 15+ packages (наши и CLI)
- 15+ httptriggers
### 4. Сравнительный анализ
Для каждого ресурса: поле-за-полем наш payload vs CLI payload vs канон types.go.
## Ключевые находки
### Что правильно
- environment: version, runtime.image, poolsize — ок
- package: deployment.literal base64 — ок
- function: InvokeStrategy structure, package.functionName — ок
- httptrigger: relativeurl, methods, functionref, createingress — ок
- client.go: чистый CRUD, GVR правильные — ок
### Что отсутствует (критично)
1. **Environment.builder** — нет builder_image/builder_command → Go не работает через builder pipeline
2. **Package source archive** — только deployment-only, нет source+build flow
### Что отсутствует (важно)
3. **Function executor_type** — hardcoded poolmgr, нет newdeploy/container
4. **Function timeouts** — нет functionTimeout, idleTimeout
5. **Function scaling** — нет minScale, maxScale
### Что отсутствует (некритично)
6. Package: пустой `source: {}` — мусор но не bug
7. Environment: resources, imagepullsecret, keeparchive
8. Function: concurrency, requestsPerPod, resources, secrets
9. HTTPTrigger: prefix, keepPrefix, полный ingressconfig
## Решение
Написан полный аудит-документ: `doc/AUDIT_PROVIDER_VS_FISSION_2026-06-03.md`
+1 -1
View File
@@ -1,2 +1,2 @@
def main(): def main():
return "ok-auto-func-UPDATED-v2" return "ok-auto-func-UPDATED-v3-with-comment"
+5
View File
@@ -0,0 +1,5 @@
def good_function():
return "correct"
def main():
return "this is main"
+39
View File
@@ -0,0 +1,39 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "python" {
name = "tf-bad-entry-env"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-bad-entry-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-bad-entry-fn"
environment = fission_environment.python.name
package_name = fission_package.pkg.name
entrypoint = "main.nonexistent_function" # Wrong entrypoint
}
resource "fission_http_trigger" "route" {
name = "tf-bad-entry-route"
url = "/bad-entrypoint"
methods = ["GET"]
function = fission_function.fn.name
}
+7
View File
@@ -0,0 +1,7 @@
def fib(n):
if n <= 1:
return n
return fib(n-1) + fib(n-2)
def main():
return f"fib(100)={fib(100)}"
+39
View File
@@ -0,0 +1,39 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "python" {
name = "tf-deep-recursion-env"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-deep-recursion-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-deep-recursion-fn"
environment = fission_environment.python.name
package_name = fission_package.pkg.name
entrypoint = "main.main"
}
resource "fission_http_trigger" "route" {
name = "tf-deep-recursion-route"
function = fission_function.fn.name
url = "/deep-recursion"
methods = ["GET"]
}
+1
View File
@@ -0,0 +1 @@
def main(): return "env-1"
+1
View File
@@ -0,0 +1 @@
def main(): return "env-2"
+1
View File
@@ -0,0 +1 @@
def main(): return "env-3"
+1
View File
@@ -0,0 +1 @@
def main(): return "env-4"
+1
View File
@@ -0,0 +1 @@
def main(): return "env-5"
+1
View File
@@ -0,0 +1 @@
def main(): return "v10"
+39
View File
@@ -0,0 +1,39 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "python" {
name = "tf-freq-update-env"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-freq-update-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-freq-update-fn"
environment = fission_environment.python.name
package_name = fission_package.pkg.name
entrypoint = "main.main"
}
resource "fission_http_trigger" "route" {
name = "tf-freq-update-route"
function = fission_function.fn.name
url = "/freq-update"
methods = ["GET"]
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/user/fn
go 1.23
+14
View File
@@ -0,0 +1,14 @@
package main
import (
"fmt"
"net/http"
)
// Handler — точка входа для Fission go-env.
// Go builder компилирует этот файл в .so плагин,
// go-env загружает его через plugin.Open().
func Handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintf(w, "Hello from real Go in Fission (builder pipeline)")
}
+46
View File
@@ -0,0 +1,46 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
# Настоящий Go через builder pipeline:
# go-builder компилирует handler.go → .so плагин
# go-env загружает .so через plugin.Open()
resource "fission_environment" "go" {
name = "tf-go-hello-env"
image = "ghcr.io/fission/go-env"
builder_image = "ghcr.io/fission/go-builder"
builder_command = "build"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-go-hello-pkg"
environment = fission_environment.go.name
source_dir = "${path.module}/code"
deploy_type = "source"
build_command = "build"
}
resource "fission_function" "fn" {
name = "tf-go-hello-fn"
environment = fission_environment.go.name
package_name = fission_package.pkg.name
entrypoint = "Handler"
}
resource "fission_http_trigger" "route" {
name = "tf-go-hello-route"
function = fission_function.fn.name
url = "/go-hello"
methods = ["GET"]
}
+2
View File
@@ -0,0 +1,2 @@
def main():
return "test"
+33
View File
@@ -0,0 +1,33 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
# Missing image — should fail
resource "fission_environment" "bad_env" {
name = "tf-invalid-env"
# image = "..." # MISSING REQUIRED FIELD
version = 3
}
resource "fission_package" "pkg" {
name = "tf-invalid-pkg"
environment = fission_environment.bad_env.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-invalid-fn"
environment = fission_environment.bad_env.name
package_name = fission_package.pkg.name
entrypoint = "main.main"
}
+2
View File
@@ -0,0 +1,2 @@
def main():
return "test"
+20
View File
@@ -0,0 +1,20 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_http_trigger" "route" {
name = "tf-missing-ref-route"
url = "/missing-ref"
methods = ["GET"]
function = "tf-missing-ref-fn"
}
+2
View File
@@ -0,0 +1,2 @@
def main():
return "env-1"
+39
View File
@@ -0,0 +1,39 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "python" {
name = "tf-multi-env-1"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-multi-env-1-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-multi-env-1-fn"
environment = fission_environment.python.name
package_name = fission_package.pkg.name
entrypoint = "main.main"
}
resource "fission_http_trigger" "route" {
name = "tf-multi-env-1-route"
function = fission_function.fn.name
url = "/multi-env-1"
methods = ["GET"]
}
+2
View File
@@ -0,0 +1,2 @@
def main():
return "env-2"
+39
View File
@@ -0,0 +1,39 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "python" {
name = "tf-multi-env-2"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-multi-env-2-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-multi-env-2-fn"
environment = fission_environment.python.name
package_name = fission_package.pkg.name
entrypoint = "main.main"
}
resource "fission_http_trigger" "route" {
name = "tf-multi-env-2-route"
function = fission_function.fn.name
url = "/multi-env-2"
methods = ["GET"]
}
+2
View File
@@ -0,0 +1,2 @@
def main():
return "env-3"
+39
View File
@@ -0,0 +1,39 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "python" {
name = "tf-multi-env-3"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-multi-env-3-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-multi-env-3-fn"
environment = fission_environment.python.name
package_name = fission_package.pkg.name
entrypoint = "main.main"
}
resource "fission_http_trigger" "route" {
name = "tf-multi-env-3-route"
function = fission_function.fn.name
url = "/multi-env-3"
methods = ["GET"]
}
@@ -0,0 +1,4 @@
import nonexistent_module_xyz_12345
def main():
return nonexistent_module_xyz_12345.do_something()
+39
View File
@@ -0,0 +1,39 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "python" {
name = "tf-neg-badimport-env"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-neg-badimport-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-neg-badimport-fn"
environment = fission_environment.python.name
package_name = fission_package.pkg.name
entrypoint = "main.main"
}
resource "fission_http_trigger" "route" {
name = "tf-neg-badimport-route"
function = fission_function.fn.name
url = "/neg/badimport"
methods = ["GET"]
}
+20
View File
@@ -0,0 +1,20 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
# Пытаемся создать environment с именем, которое уже существует (tf-python-env из hello-python)
resource "fission_environment" "conflict" {
name = "tf-python-env"
image = "ghcr.io/fission/python-env"
version = 3
}
+2
View File
@@ -0,0 +1,2 @@
def not_main():
return "there is no main() here, Fission will fail to invoke"
+39
View File
@@ -0,0 +1,39 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "python" {
name = "tf-neg-nomain-env"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-neg-nomain-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-neg-nomain-fn"
environment = fission_environment.python.name
package_name = fission_package.pkg.name
entrypoint = "main.main"
}
resource "fission_http_trigger" "route" {
name = "tf-neg-nomain-route"
function = fission_function.fn.name
url = "/neg/nomain"
methods = ["GET"]
}
@@ -0,0 +1,3 @@
def main():
x = 1 / 0
return f"result: {x}"
+39
View File
@@ -0,0 +1,39 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "python" {
name = "tf-neg-rterr-env"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-neg-rterr-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-neg-rterr-fn"
environment = fission_environment.python.name
package_name = fission_package.pkg.name
entrypoint = "main.main"
}
resource "fission_http_trigger" "route" {
name = "tf-neg-rterr-route"
function = fission_function.fn.name
url = "/neg/rterr"
methods = ["GET"]
}
@@ -0,0 +1,2 @@
def main(:
return "this should never work"
+39
View File
@@ -0,0 +1,39 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "python" {
name = "tf-neg-syntax-env"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-neg-syntax-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-neg-syntax-fn"
environment = fission_environment.python.name
package_name = fission_package.pkg.name
entrypoint = "main.main"
}
resource "fission_http_trigger" "route" {
name = "tf-neg-syntax-route"
function = fission_function.fn.name
url = "/neg/syntax"
methods = ["GET"]
}
+2
View File
@@ -0,0 +1,2 @@
def main():
return "orphan-test"
+39
View File
@@ -0,0 +1,39 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "python" {
name = "tf-orphan-env"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-orphan-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-orphan-fn"
environment = fission_environment.python.name
package_name = fission_package.pkg.name
entrypoint = "main.main"
}
resource "fission_http_trigger" "route" {
name = "tf-orphan-route"
function = fission_function.fn.name
url = "/orphan-test"
methods = ["GET"]
}
+3
View File
@@ -0,0 +1,3 @@
sub {
return "Hello from Perl in Fission";
}
+40
View File
@@ -0,0 +1,40 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
# version = 1: Perl env uses v1 specialization (/specialize endpoint)
resource "fission_environment" "perl" {
name = "tf-perl-hello-env"
image = "ghcr.io/fission/perl-env"
version = 1
}
resource "fission_package" "pkg" {
name = "tf-perl-hello-pkg"
environment = fission_environment.perl.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-perl-hello-fn"
environment = fission_environment.perl.name
package_name = fission_package.pkg.name
entrypoint = "handler"
}
resource "fission_http_trigger" "route" {
name = "tf-perl-hello-route"
function = fission_function.fn.name
url = "/perl-hello"
methods = ["GET"]
}
+7
View File
@@ -0,0 +1,7 @@
<?php
function handler($context)
{
/** @var \Psr\Http\Message\ResponseInterface $response */
$response = $context["response"];
$response->getBody()->write("Hello from PHP in Fission");
}
+39
View File
@@ -0,0 +1,39 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "php" {
name = "tf-php-hello-env"
image = "ghcr.io/fission/php-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-php-hello-pkg"
environment = fission_environment.php.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-php-hello-fn"
environment = fission_environment.php.name
package_name = fission_package.pkg.name
entrypoint = "main.php::handler"
}
resource "fission_http_trigger" "route" {
name = "tf-php-hello-route"
function = fission_function.fn.name
url = "/php-hello"
methods = ["GET"]
}
+5
View File
@@ -0,0 +1,5 @@
# frozen_string_literal: true
def handler
"Hello from Ruby in Fission"
end
+39
View File
@@ -0,0 +1,39 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "ruby" {
name = "tf-ruby-hello-env"
image = "ghcr.io/fission/ruby-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-ruby-hello-pkg"
environment = fission_environment.ruby.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-ruby-hello-fn"
environment = fission_environment.ruby.name
package_name = fission_package.pkg.name
entrypoint = "handler"
}
resource "fission_http_trigger" "route" {
name = "tf-ruby-hello-route"
function = fission_function.fn.name
url = "/ruby-hello"
methods = ["GET"]
}
@@ -0,0 +1,2 @@
def exists():
return "x"
+32
View File
@@ -0,0 +1,32 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "env" {
name = "tf-validate-bad-entry-env"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-validate-bad-entry-pkg"
environment = fission_environment.env.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-validate-bad-entry-fn"
environment = fission_environment.env.name
package_name = fission_package.pkg.name
entrypoint = "main.main"
}
@@ -0,0 +1,2 @@
def main():
return "x"
+19
View File
@@ -0,0 +1,19 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_package" "pkg" {
name = "tf-validate-missing-env-pkg"
environment = "env-does-not-exist-xyz"
source_dir = "${path.module}/code"
}
@@ -24,13 +24,15 @@ type EnvironmentResource struct {
} }
type environmentResourceModel struct { type environmentResourceModel struct {
ID types.String `tfsdk:"id"` ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"` Name types.String `tfsdk:"name"`
Image types.String `tfsdk:"image"` Image types.String `tfsdk:"image"`
Version types.Int64 `tfsdk:"version"` Version types.Int64 `tfsdk:"version"`
PoolSize types.Int64 `tfsdk:"poolsize"` PoolSize types.Int64 `tfsdk:"poolsize"`
Namespace types.String `tfsdk:"namespace"` BuilderImage types.String `tfsdk:"builder_image"`
UID types.String `tfsdk:"uid"` BuilderCommand types.String `tfsdk:"builder_command"`
Namespace types.String `tfsdk:"namespace"`
UID types.String `tfsdk:"uid"`
} }
func NewEnvironmentResource() resource.Resource { func NewEnvironmentResource() resource.Resource {
@@ -68,6 +70,14 @@ func (r *EnvironmentResource) Schema(_ context.Context, _ resource.SchemaRequest
Default: int64default.StaticInt64(3), Default: int64default.StaticInt64(3),
Description: "Размер пула pre-warmed контейнеров.", Description: "Размер пула pre-warmed контейнеров.",
}, },
"builder_image": schema.StringAttribute{
Optional: true,
Description: "Builder image для Environment (например ghcr.io/fission/go-builder). Нужен для языков с build step (Go и др.).",
},
"builder_command": schema.StringAttribute{
Optional: true,
Description: "Команда сборки в builder контейнере (например 'build').",
},
"namespace": schema.StringAttribute{ "namespace": schema.StringAttribute{
Optional: true, Optional: true,
Computed: true, Computed: true,
@@ -216,6 +226,26 @@ func (r *EnvironmentResource) ImportState(ctx context.Context, req resource.Impo
// environmentToUnstructured преобразует Terraform model в Kubernetes CRD payload. // environmentToUnstructured преобразует Terraform model в Kubernetes CRD payload.
func environmentToUnstructured(model environmentResourceModel, namespace string) *unstructured.Unstructured { func environmentToUnstructured(model environmentResourceModel, namespace string) *unstructured.Unstructured {
spec := map[string]interface{}{
"version": model.Version.ValueInt64(),
"runtime": map[string]interface{}{
"image": model.Image.ValueString(),
},
"poolsize": model.PoolSize.ValueInt64(),
}
builderImage := model.BuilderImage.ValueString()
if builderImage != "" {
builder := map[string]interface{}{
"image": builderImage,
}
builderCmd := model.BuilderCommand.ValueString()
if builderCmd != "" {
builder["command"] = builderCmd
}
spec["builder"] = builder
}
return &unstructured.Unstructured{Object: map[string]interface{}{ return &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "fission.io/v1", "apiVersion": "fission.io/v1",
"kind": "Environment", "kind": "Environment",
@@ -223,13 +253,7 @@ func environmentToUnstructured(model environmentResourceModel, namespace string)
"name": model.Name.ValueString(), "name": model.Name.ValueString(),
"namespace": namespace, "namespace": namespace,
}, },
"spec": map[string]interface{}{ "spec": spec,
"version": model.Version.ValueInt64(),
"runtime": map[string]interface{}{
"image": model.Image.ValueString(),
},
"poolsize": model.PoolSize.ValueInt64(),
},
}} }}
} }
@@ -238,6 +262,8 @@ func unstructuredToEnvironmentModel(environmentObject *unstructured.Unstructured
imageValue, _, _ := unstructured.NestedString(environmentObject.Object, "spec", "runtime", "image") imageValue, _, _ := unstructured.NestedString(environmentObject.Object, "spec", "runtime", "image")
versionValue, _, _ := unstructured.NestedInt64(environmentObject.Object, "spec", "version") versionValue, _, _ := unstructured.NestedInt64(environmentObject.Object, "spec", "version")
poolsizeValue, _, _ := unstructured.NestedInt64(environmentObject.Object, "spec", "poolsize") poolsizeValue, _, _ := unstructured.NestedInt64(environmentObject.Object, "spec", "poolsize")
builderImage, _, _ := unstructured.NestedString(environmentObject.Object, "spec", "builder", "image")
builderCommand, _, _ := unstructured.NestedString(environmentObject.Object, "spec", "builder", "command")
state := base state := base
state.Name = types.StringValue(environmentObject.GetName()) state.Name = types.StringValue(environmentObject.GetName())
@@ -254,6 +280,12 @@ func unstructuredToEnvironmentModel(environmentObject *unstructured.Unstructured
if poolsizeValue != 0 { if poolsizeValue != 0 {
state.PoolSize = types.Int64Value(poolsizeValue) state.PoolSize = types.Int64Value(poolsizeValue)
} }
if builderImage != "" {
state.BuilderImage = types.StringValue(builderImage)
}
if builderCommand != "" {
state.BuilderCommand = types.StringValue(builderCommand)
}
return state return state
} }
@@ -4,6 +4,7 @@ import (
"testing" "testing"
"github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
) )
func TestEnvironmentToUnstructuredAndBack(t *testing.T) { func TestEnvironmentToUnstructuredAndBack(t *testing.T) {
@@ -33,3 +34,51 @@ func TestEnvironmentToUnstructuredAndBack(t *testing.T) {
t.Fatalf("unexpected poolsize: %d", state.PoolSize.ValueInt64()) t.Fatalf("unexpected poolsize: %d", state.PoolSize.ValueInt64())
} }
} }
func TestEnvironmentToUnstructuredWithBuilder(t *testing.T) {
input := environmentResourceModel{
Name: types.StringValue("go-env"),
Image: types.StringValue("ghcr.io/fission/go-env"),
Version: types.Int64Value(3),
PoolSize: types.Int64Value(3),
BuilderImage: types.StringValue("ghcr.io/fission/go-builder"),
BuilderCommand: types.StringValue("build"),
}
obj := environmentToUnstructured(input, "default")
state := unstructuredToEnvironmentModel(obj, input)
if state.BuilderImage.ValueString() != "ghcr.io/fission/go-builder" {
t.Fatalf("unexpected builder_image: %q", state.BuilderImage.ValueString())
}
if state.BuilderCommand.ValueString() != "build" {
t.Fatalf("unexpected builder_command: %q", state.BuilderCommand.ValueString())
}
// Verify the unstructured object has builder section
builderImage, found, _ := unstructured.NestedString(obj.Object, "spec", "builder", "image")
if !found || builderImage != "ghcr.io/fission/go-builder" {
t.Fatalf("builder.image not set correctly in unstructured: %q", builderImage)
}
builderCmd, found, _ := unstructured.NestedString(obj.Object, "spec", "builder", "command")
if !found || builderCmd != "build" {
t.Fatalf("builder.command not set correctly in unstructured: %q", builderCmd)
}
}
func TestEnvironmentToUnstructuredWithoutBuilder(t *testing.T) {
input := environmentResourceModel{
Name: types.StringValue("py-env"),
Image: types.StringValue("ghcr.io/fission/python-env"),
Version: types.Int64Value(3),
PoolSize: types.Int64Value(3),
}
obj := environmentToUnstructured(input, "default")
// Verify no builder section when builder_image is not set
_, found, _ := unstructured.NestedString(obj.Object, "spec", "builder", "image")
if found {
t.Fatalf("builder should not be present when builder_image is not set")
}
}
@@ -9,6 +9,7 @@ import (
"github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
"github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -26,13 +27,18 @@ type FunctionResource struct {
// functionResourceModel описывает состояние terraform ресурса fission_function. // functionResourceModel описывает состояние terraform ресурса fission_function.
type functionResourceModel struct { type functionResourceModel struct {
ID types.String `tfsdk:"id"` ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"` Name types.String `tfsdk:"name"`
Environment types.String `tfsdk:"environment"` Environment types.String `tfsdk:"environment"`
PackageName types.String `tfsdk:"package_name"` PackageName types.String `tfsdk:"package_name"`
Entrypoint types.String `tfsdk:"entrypoint"` Entrypoint types.String `tfsdk:"entrypoint"`
Namespace types.String `tfsdk:"namespace"` ExecutorType types.String `tfsdk:"executor_type"`
UID types.String `tfsdk:"uid"` FunctionTimeout types.Int64 `tfsdk:"function_timeout"`
IdleTimeout types.Int64 `tfsdk:"idle_timeout"`
MinScale types.Int64 `tfsdk:"min_scale"`
MaxScale types.Int64 `tfsdk:"max_scale"`
Namespace types.String `tfsdk:"namespace"`
UID types.String `tfsdk:"uid"`
} }
// NewFunctionResource создает инстанс ресурса функции. // NewFunctionResource создает инстанс ресурса функции.
@@ -69,6 +75,28 @@ func (r *FunctionResource) Schema(_ context.Context, _ resource.SchemaRequest, r
Required: true, Required: true,
Description: "Имя точки входа в пакете (например main.main).", Description: "Имя точки входа в пакете (например main.main).",
}, },
"executor_type": schema.StringAttribute{
Optional: true,
Computed: true,
Default: stringdefault.StaticString("poolmgr"),
Description: "Тип executor: poolmgr (default), newdeploy или container.",
},
"function_timeout": schema.Int64Attribute{
Optional: true,
Description: "Таймаут выполнения функции в секундах (Fission default: 60).",
},
"idle_timeout": schema.Int64Attribute{
Optional: true,
Description: "Время простоя до scale-to-zero в секундах (Fission default: 120).",
},
"min_scale": schema.Int64Attribute{
Optional: true,
Description: "Минимальное число реплик (для newdeploy/container).",
},
"max_scale": schema.Int64Attribute{
Optional: true,
Description: "Максимальное число реплик (для newdeploy/container).",
},
"namespace": schema.StringAttribute{ "namespace": schema.StringAttribute{
Optional: true, Optional: true,
Computed: true, Computed: true,
@@ -235,6 +263,46 @@ func (r *FunctionResource) ImportState(ctx context.Context, req resource.ImportS
// functionToUnstructured преобразует Terraform model в Kubernetes CRD payload. // functionToUnstructured преобразует Terraform model в Kubernetes CRD payload.
func functionToUnstructured(model functionResourceModel, namespace string) *unstructured.Unstructured { func functionToUnstructured(model functionResourceModel, namespace string) *unstructured.Unstructured {
executorType := "poolmgr"
if !model.ExecutorType.IsNull() && !model.ExecutorType.IsUnknown() && model.ExecutorType.ValueString() != "" {
executorType = model.ExecutorType.ValueString()
}
executionStrategy := map[string]interface{}{
"ExecutorType": executorType,
}
if !model.MinScale.IsNull() && !model.MinScale.IsUnknown() {
executionStrategy["MinScale"] = model.MinScale.ValueInt64()
}
if !model.MaxScale.IsNull() && !model.MaxScale.IsUnknown() {
executionStrategy["MaxScale"] = model.MaxScale.ValueInt64()
}
spec := map[string]interface{}{
"environment": map[string]interface{}{
"name": model.Environment.ValueString(),
"namespace": namespace,
},
"InvokeStrategy": map[string]interface{}{
"ExecutionStrategy": executionStrategy,
"StrategyType": "execution",
},
"package": map[string]interface{}{
"packageref": map[string]interface{}{
"name": model.PackageName.ValueString(),
"namespace": namespace,
},
"functionName": model.Entrypoint.ValueString(),
},
}
if !model.FunctionTimeout.IsNull() && !model.FunctionTimeout.IsUnknown() {
spec["functionTimeout"] = model.FunctionTimeout.ValueInt64()
}
if !model.IdleTimeout.IsNull() && !model.IdleTimeout.IsUnknown() {
spec["idletimeout"] = model.IdleTimeout.ValueInt64()
}
return &unstructured.Unstructured{Object: map[string]interface{}{ return &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "fission.io/v1", "apiVersion": "fission.io/v1",
"kind": "Function", "kind": "Function",
@@ -242,25 +310,7 @@ func functionToUnstructured(model functionResourceModel, namespace string) *unst
"name": model.Name.ValueString(), "name": model.Name.ValueString(),
"namespace": namespace, "namespace": namespace,
}, },
"spec": map[string]interface{}{ "spec": spec,
"environment": map[string]interface{}{
"name": model.Environment.ValueString(),
"namespace": namespace,
},
"InvokeStrategy": map[string]interface{}{
"ExecutionStrategy": map[string]interface{}{
"ExecutorType": "poolmgr",
},
"StrategyType": "execution",
},
"package": map[string]interface{}{
"packageref": map[string]interface{}{
"name": model.PackageName.ValueString(),
"namespace": namespace,
},
"functionName": model.Entrypoint.ValueString(),
},
},
}} }}
} }
@@ -269,6 +319,11 @@ func unstructuredToFunctionModel(functionObject *unstructured.Unstructured, base
environmentName, _, _ := unstructured.NestedString(functionObject.Object, "spec", "environment", "name") environmentName, _, _ := unstructured.NestedString(functionObject.Object, "spec", "environment", "name")
packageName, _, _ := unstructured.NestedString(functionObject.Object, "spec", "package", "packageref", "name") packageName, _, _ := unstructured.NestedString(functionObject.Object, "spec", "package", "packageref", "name")
entrypoint, _, _ := unstructured.NestedString(functionObject.Object, "spec", "package", "functionName") entrypoint, _, _ := unstructured.NestedString(functionObject.Object, "spec", "package", "functionName")
executorType, _, _ := unstructured.NestedString(functionObject.Object, "spec", "InvokeStrategy", "ExecutionStrategy", "ExecutorType")
functionTimeout, foundFT, _ := unstructured.NestedInt64(functionObject.Object, "spec", "functionTimeout")
idleTimeout, foundIT, _ := unstructured.NestedInt64(functionObject.Object, "spec", "idletimeout")
minScale, foundMin, _ := unstructured.NestedInt64(functionObject.Object, "spec", "InvokeStrategy", "ExecutionStrategy", "MinScale")
maxScale, foundMax, _ := unstructured.NestedInt64(functionObject.Object, "spec", "InvokeStrategy", "ExecutionStrategy", "MaxScale")
state := base state := base
state.Name = types.StringValue(functionObject.GetName()) state.Name = types.StringValue(functionObject.GetName())
@@ -285,14 +340,36 @@ func unstructuredToFunctionModel(functionObject *unstructured.Unstructured, base
if entrypoint != "" { if entrypoint != "" {
state.Entrypoint = types.StringValue(entrypoint) state.Entrypoint = types.StringValue(entrypoint)
} }
if executorType != "" {
state.ExecutorType = types.StringValue(executorType)
}
if foundFT {
state.FunctionTimeout = types.Int64Value(functionTimeout)
}
if foundIT {
state.IdleTimeout = types.Int64Value(idleTimeout)
}
if foundMin {
state.MinScale = types.Int64Value(minScale)
}
if foundMax {
state.MaxScale = types.Int64Value(maxScale)
}
return state return state
} }
func validateEntrypointAgainstPackageSource(entrypoint string, pkg *unstructured.Unstructured) error { func validateEntrypointAgainstPackageSource(entrypoint string, pkg *unstructured.Unstructured) error {
if entrypoint == "" {
return fmt.Errorf("entrypoint не может быть пустым")
}
// Для Python/Go/JS: валидируем формат module.function и наличие функции в исходнике.
// Для PHP (module::function), Ruby (function), Perl (function) — допускаем любой непустой формат.
parts := strings.Split(entrypoint, ".") parts := strings.Split(entrypoint, ".")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" { if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return fmt.Errorf("entrypoint %q должен иметь формат module.function", entrypoint) // Не стандартный module.function — допускаем (PHP, Ruby, Perl и др.)
return nil
} }
if parts[0] != "main" { if parts[0] != "main" {
@@ -31,6 +31,9 @@ func TestFunctionToUnstructuredAndBack(t *testing.T) {
if state.Entrypoint.ValueString() != "main.main" { if state.Entrypoint.ValueString() != "main.main" {
t.Fatalf("unexpected entrypoint: %q", state.Entrypoint.ValueString()) t.Fatalf("unexpected entrypoint: %q", state.Entrypoint.ValueString())
} }
if state.ExecutorType.ValueString() != "poolmgr" {
t.Fatalf("unexpected executor_type: %q", state.ExecutorType.ValueString())
}
invoke, found, err := unstructured.NestedMap(obj.Object, "spec", "InvokeStrategy") invoke, found, err := unstructured.NestedMap(obj.Object, "spec", "InvokeStrategy")
if err != nil || !found || len(invoke) == 0 { if err != nil || !found || len(invoke) == 0 {
@@ -38,6 +41,45 @@ func TestFunctionToUnstructuredAndBack(t *testing.T) {
} }
} }
func TestFunctionToUnstructuredWithTimeouts(t *testing.T) {
input := functionResourceModel{
Name: types.StringValue("fn-b"),
Environment: types.StringValue("env-a"),
PackageName: types.StringValue("pkg-a"),
Entrypoint: types.StringValue("main.main"),
ExecutorType: types.StringValue("newdeploy"),
FunctionTimeout: types.Int64Value(120),
IdleTimeout: types.Int64Value(60),
MinScale: types.Int64Value(1),
MaxScale: types.Int64Value(5),
}
obj := functionToUnstructured(input, "default")
state := unstructuredToFunctionModel(obj, input)
if state.ExecutorType.ValueString() != "newdeploy" {
t.Fatalf("unexpected executor_type: %q", state.ExecutorType.ValueString())
}
if state.FunctionTimeout.ValueInt64() != 120 {
t.Fatalf("unexpected function_timeout: %d", state.FunctionTimeout.ValueInt64())
}
if state.IdleTimeout.ValueInt64() != 60 {
t.Fatalf("unexpected idle_timeout: %d", state.IdleTimeout.ValueInt64())
}
if state.MinScale.ValueInt64() != 1 {
t.Fatalf("unexpected min_scale: %d", state.MinScale.ValueInt64())
}
if state.MaxScale.ValueInt64() != 5 {
t.Fatalf("unexpected max_scale: %d", state.MaxScale.ValueInt64())
}
// Verify executor type in unstructured
et, _, _ := unstructured.NestedString(obj.Object, "spec", "InvokeStrategy", "ExecutionStrategy", "ExecutorType")
if et != "newdeploy" {
t.Fatalf("unexpected ExecutorType in unstructured: %q", et)
}
}
func TestValidateEntrypointAgainstPackageSourcePythonOK(t *testing.T) { func TestValidateEntrypointAgainstPackageSourcePythonOK(t *testing.T) {
source := "def main():\n return 'ok'\n" source := "def main():\n return 'ok'\n"
pkg := &unstructured.Unstructured{Object: map[string]interface{}{ pkg := &unstructured.Unstructured{Object: map[string]interface{}{
@@ -70,7 +112,12 @@ func TestValidateEntrypointAgainstPackageSourcePythonMissing(t *testing.T) {
func TestValidateEntrypointAgainstPackageSourceBadFormat(t *testing.T) { func TestValidateEntrypointAgainstPackageSourceBadFormat(t *testing.T) {
pkg := &unstructured.Unstructured{} pkg := &unstructured.Unstructured{}
if err := validateEntrypointAgainstPackageSource("main", pkg); err == nil { // Пустой entrypoint должен быть ошибкой
t.Fatalf("expected validation error for bad entrypoint format") if err := validateEntrypointAgainstPackageSource("", pkg); err == nil {
t.Fatalf("expected validation error for empty entrypoint")
}
// Одиночное слово допустимо (Ruby, Perl)
if err := validateEntrypointAgainstPackageSource("handler", pkg); err != nil {
t.Fatalf("unexpected error for single-word entrypoint: %v", err)
} }
} }
@@ -1,10 +1,13 @@
package resources package resources
import ( import (
"archive/zip"
"bytes"
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
@@ -12,6 +15,7 @@ import (
"github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
"github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -37,6 +41,7 @@ type packageResourceModel struct {
CodePath types.String `tfsdk:"code_path"` CodePath types.String `tfsdk:"code_path"`
CodeHash types.String `tfsdk:"code_hash"` CodeHash types.String `tfsdk:"code_hash"`
BuildCmd types.String `tfsdk:"build_command"` BuildCmd types.String `tfsdk:"build_command"`
DeployType types.String `tfsdk:"deploy_type"`
Namespace types.String `tfsdk:"namespace"` Namespace types.String `tfsdk:"namespace"`
UID types.String `tfsdk:"uid"` UID types.String `tfsdk:"uid"`
BuildStatus types.String `tfsdk:"build_status"` BuildStatus types.String `tfsdk:"build_status"`
@@ -71,7 +76,7 @@ func (r *PackageResource) Schema(_ context.Context, _ resource.SchemaRequest, re
}, },
"source_dir": schema.StringAttribute{ "source_dir": schema.StringAttribute{
Optional: true, Optional: true,
Description: "Путь к директории с кодом (поддерживаются main.py, main.js, main.go).", Description: "Путь к директории с кодом (поддерживаются main.py, main.js, main.go, main.php, main.rb, main.pl).",
}, },
"code_path": schema.StringAttribute{ "code_path": schema.StringAttribute{
Optional: true, Optional: true,
@@ -86,6 +91,12 @@ func (r *PackageResource) Schema(_ context.Context, _ resource.SchemaRequest, re
Optional: true, Optional: true,
Description: "Команда сборки пакета в Fission.", Description: "Команда сборки пакета в Fission.",
}, },
"deploy_type": schema.StringAttribute{
Optional: true,
Computed: true,
Default: stringdefault.StaticString("literal"),
Description: "Тип деплоя: 'literal' (default) — код в deployment.literal, 'source' — код в source.literal (для Go и языков с build step).",
},
"namespace": schema.StringAttribute{ "namespace": schema.StringAttribute{
Optional: true, Optional: true,
Computed: true, Computed: true,
@@ -137,7 +148,7 @@ func (r *PackageResource) ModifyPlan(ctx context.Context, req resource.ModifyPla
return return
} }
literalBytes, err := loadPackageLiteral(plan.SourceDir.ValueString(), plan.CodePath.ValueString()) literalBytes, err := loadPackageContent(plan.SourceDir.ValueString(), plan.CodePath.ValueString(), plan.DeployType.ValueString())
if err != nil { if err != nil {
resp.Diagnostics.AddError("Ошибка чтения исходного кода пакета", err.Error()) resp.Diagnostics.AddError("Ошибка чтения исходного кода пакета", err.Error())
return return
@@ -183,7 +194,7 @@ func (r *PackageResource) Create(ctx context.Context, req resource.CreateRequest
return return
} }
literalBytes, err := loadPackageLiteral(plan.SourceDir.ValueString(), plan.CodePath.ValueString()) literalBytes, err := loadPackageContent(plan.SourceDir.ValueString(), plan.CodePath.ValueString(), plan.DeployType.ValueString())
if err != nil { if err != nil {
resp.Diagnostics.AddError("Ошибка чтения исходного кода пакета", err.Error()) resp.Diagnostics.AddError("Ошибка чтения исходного кода пакета", err.Error())
return return
@@ -246,7 +257,7 @@ func (r *PackageResource) Update(ctx context.Context, req resource.UpdateRequest
return return
} }
literalBytes, err := loadPackageLiteral(plan.SourceDir.ValueString(), plan.CodePath.ValueString()) literalBytes, err := loadPackageContent(plan.SourceDir.ValueString(), plan.CodePath.ValueString(), plan.DeployType.ValueString())
if err != nil { if err != nil {
resp.Diagnostics.AddError("Ошибка чтения исходного кода пакета", err.Error()) resp.Diagnostics.AddError("Ошибка чтения исходного кода пакета", err.Error())
return return
@@ -329,7 +340,7 @@ func resolveNamespace(resourceNamespace types.String, providerNamespace string)
return namespace return namespace
} }
// loadPackageLiteral читает bytes для spec.deployment.literal. // loadPackageLiteral читает bytes для literal deployment (один файл).
func loadPackageLiteral(sourceDir, codePath string) ([]byte, error) { func loadPackageLiteral(sourceDir, codePath string) ([]byte, error) {
if sourceDir != "" { if sourceDir != "" {
mainFilePath, err := resolveMainSourceFile(sourceDir) mainFilePath, err := resolveMainSourceFile(sourceDir)
@@ -353,9 +364,60 @@ func loadPackageLiteral(sourceDir, codePath string) ([]byte, error) {
return literalBytes, nil return literalBytes, nil
} }
// loadPackageSourceArchive создает zip-архив из source_dir для builder pipeline.
func loadPackageSourceArchive(sourceDir string) ([]byte, error) {
var buf bytes.Buffer
zipWriter := zip.NewWriter(&buf)
err := filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
relPath, err := filepath.Rel(sourceDir, path)
if err != nil {
return fmt.Errorf("compute relative path for %q: %w", path, err)
}
writer, err := zipWriter.Create(relPath)
if err != nil {
return fmt.Errorf("create zip entry %q: %w", relPath, err)
}
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("open file %q: %w", path, err)
}
defer file.Close()
_, err = io.Copy(writer, file)
return err
})
if err != nil {
return nil, fmt.Errorf("zip source_dir %q: %w", sourceDir, err)
}
if err := zipWriter.Close(); err != nil {
return nil, fmt.Errorf("close zip writer: %w", err)
}
return buf.Bytes(), nil
}
// loadPackageContent загружает содержимое пакета в зависимости от deploy_type.
func loadPackageContent(sourceDir, codePath, deployType string) ([]byte, error) {
if deployType == "source" && sourceDir != "" {
return loadPackageSourceArchive(sourceDir)
}
return loadPackageLiteral(sourceDir, codePath)
}
// resolveMainSourceFile выбирает основной файл исходника из source_dir. // resolveMainSourceFile выбирает основной файл исходника из source_dir.
func resolveMainSourceFile(sourceDir string) (string, error) { func resolveMainSourceFile(sourceDir string) (string, error) {
candidates := []string{"main.py", "main.js", "main.go"} candidates := []string{"main.py", "main.js", "main.go", "main.php", "main.rb", "main.pl"}
for _, candidate := range candidates { for _, candidate := range candidates {
candidatePath := filepath.Join(sourceDir, candidate) candidatePath := filepath.Join(sourceDir, candidate)
fileInfo, err := os.Stat(candidatePath) fileInfo, err := os.Stat(candidatePath)
@@ -364,13 +426,43 @@ func resolveMainSourceFile(sourceDir string) (string, error) {
} }
} }
return "", fmt.Errorf("source_dir %q must contain one of: main.py, main.js, main.go", sourceDir) return "", fmt.Errorf("source_dir %q must contain one of: main.py, main.js, main.go, main.php, main.rb, main.pl", sourceDir)
} }
// packageToUnstructured преобразует Terraform model в Kubernetes CRD payload. // packageToUnstructured преобразует Terraform model в Kubernetes CRD payload.
func packageToUnstructured(model packageResourceModel, namespace string, literalBytes []byte) *unstructured.Unstructured { func packageToUnstructured(model packageResourceModel, namespace string, literalBytes []byte) *unstructured.Unstructured {
literalSource := base64.StdEncoding.EncodeToString(literalBytes) literalSource := base64.StdEncoding.EncodeToString(literalBytes)
deployType := "literal"
if !model.DeployType.IsNull() && !model.DeployType.IsUnknown() && model.DeployType.ValueString() != "" {
deployType = model.DeployType.ValueString()
}
spec := map[string]interface{}{
"environment": map[string]interface{}{
"name": model.Environment.ValueString(),
"namespace": namespace,
},
}
if deployType == "source" {
// Source mode: код в spec.source (для builder pipeline — Go и др.)
spec["source"] = map[string]interface{}{
"type": "literal",
"literal": literalSource,
}
} else {
// Literal/deployment mode: код в spec.deployment (Python, Node, PHP, Ruby, Perl)
spec["deployment"] = map[string]interface{}{
"type": "literal",
"literal": literalSource,
}
}
if buildCommand := model.BuildCmd.ValueString(); buildCommand != "" {
spec["buildcmd"] = buildCommand
}
object := map[string]interface{}{ object := map[string]interface{}{
"apiVersion": "fission.io/v1", "apiVersion": "fission.io/v1",
"kind": "Package", "kind": "Package",
@@ -378,21 +470,7 @@ func packageToUnstructured(model packageResourceModel, namespace string, literal
"name": model.Name.ValueString(), "name": model.Name.ValueString(),
"namespace": namespace, "namespace": namespace,
}, },
"spec": map[string]interface{}{ "spec": spec,
"deployment": map[string]interface{}{
"type": "literal",
"literal": literalSource,
},
"environment": map[string]interface{}{
"name": model.Environment.ValueString(),
"namespace": namespace,
},
"source": map[string]interface{}{},
},
}
if buildCommand := model.BuildCmd.ValueString(); buildCommand != "" {
_ = unstructured.SetNestedField(object, buildCommand, "spec", "buildcmd")
} }
return &unstructured.Unstructured{Object: object} return &unstructured.Unstructured{Object: object}
@@ -405,6 +483,7 @@ func unstructuredToPackageModel(packageObject *unstructured.Unstructured, base p
buildStatus, _, _ := unstructured.NestedString(packageObject.Object, "status", "buildstatus") buildStatus, _, _ := unstructured.NestedString(packageObject.Object, "status", "buildstatus")
buildLog, _, _ := unstructured.NestedString(packageObject.Object, "status", "buildlog") buildLog, _, _ := unstructured.NestedString(packageObject.Object, "status", "buildlog")
deploymentLiteral, _, _ := unstructured.NestedString(packageObject.Object, "spec", "deployment", "literal") deploymentLiteral, _, _ := unstructured.NestedString(packageObject.Object, "spec", "deployment", "literal")
sourceLiteral, _, _ := unstructured.NestedString(packageObject.Object, "spec", "source", "literal")
state := packageResourceModel{ state := packageResourceModel{
ID: types.StringValue(fmt.Sprintf("%s/%s", packageObject.GetNamespace(), packageObject.GetName())), ID: types.StringValue(fmt.Sprintf("%s/%s", packageObject.GetNamespace(), packageObject.GetName())),
@@ -414,6 +493,7 @@ func unstructuredToPackageModel(packageObject *unstructured.Unstructured, base p
CodePath: base.CodePath, CodePath: base.CodePath,
CodeHash: base.CodeHash, CodeHash: base.CodeHash,
BuildCmd: base.BuildCmd, BuildCmd: base.BuildCmd,
DeployType: base.DeployType,
Namespace: types.StringValue(packageObject.GetNamespace()), Namespace: types.StringValue(packageObject.GetNamespace()),
UID: types.StringValue(string(packageObject.GetUID())), UID: types.StringValue(string(packageObject.GetUID())),
BuildStatus: types.StringNull(), BuildStatus: types.StringNull(),
@@ -433,8 +513,13 @@ func unstructuredToPackageModel(packageObject *unstructured.Unstructured, base p
state.BuildLog = types.StringValue(buildLog) state.BuildLog = types.StringValue(buildLog)
} }
if deploymentLiteral != "" { // Определить hash из содержимого (deployment или source)
if literalBytes, err := base64.StdEncoding.DecodeString(deploymentLiteral); err == nil { literalForHash := deploymentLiteral
if literalForHash == "" {
literalForHash = sourceLiteral
}
if literalForHash != "" {
if literalBytes, err := base64.StdEncoding.DecodeString(literalForHash); err == nil {
state.CodeHash = types.StringValue(calculateCodeHash(literalBytes)) state.CodeHash = types.StringValue(calculateCodeHash(literalBytes))
} }
} }
@@ -1,6 +1,8 @@
package resources package resources
import ( import (
"archive/zip"
"bytes"
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
@@ -104,6 +106,47 @@ func TestPackageToUnstructured(t *testing.T) {
if string(decoded) != "print('hi')" { if string(decoded) != "print('hi')" {
t.Fatalf("unexpected decoded literal: %q", string(decoded)) t.Fatalf("unexpected decoded literal: %q", string(decoded))
} }
// Verify no empty source map is generated
_, sourceFound, _ := unstructured.NestedMap(obj.Object, "spec", "source")
if sourceFound {
t.Fatalf("empty source should not be present in deployment mode")
}
}
func TestPackageToUnstructuredSourceMode(t *testing.T) {
input := packageResourceModel{
Name: types.StringValue("pkg-go"),
Environment: types.StringValue("go-env"),
DeployType: types.StringValue("source"),
BuildCmd: types.StringValue("build"),
}
obj := packageToUnstructured(input, "default", []byte("zip-content"))
sourceLiteral, found, err := unstructured.NestedString(obj.Object, "spec", "source", "literal")
if err != nil || !found {
t.Fatalf("source.literal not found")
}
decoded, err := base64.StdEncoding.DecodeString(sourceLiteral)
if err != nil {
t.Fatalf("decode source literal: %v", err)
}
if string(decoded) != "zip-content" {
t.Fatalf("unexpected source literal: %q", string(decoded))
}
// Verify no deployment section in source mode
_, deployFound, _ := unstructured.NestedMap(obj.Object, "spec", "deployment")
if deployFound {
t.Fatalf("deployment should not be present in source mode")
}
// Verify buildcmd is set
buildCmd, _, _ := unstructured.NestedString(obj.Object, "spec", "buildcmd")
if buildCmd != "build" {
t.Fatalf("unexpected buildcmd: %q", buildCmd)
}
} }
func TestResolveNamespace(t *testing.T) { func TestResolveNamespace(t *testing.T) {
@@ -179,3 +222,39 @@ func TestHTTPTriggerRoundTrip(t *testing.T) {
t.Fatalf("unexpected url: %q", state.URL.ValueString()) t.Fatalf("unexpected url: %q", state.URL.ValueString())
} }
} }
func TestLoadPackageSourceArchive(t *testing.T) {
tempDir := t.TempDir()
// Create multiple files to zip
files := map[string]string{
"main.go": "package main\n\nimport \"net/http\"\n\nfunc Handler(w http.ResponseWriter, r *http.Request) {}\n",
"go.mod": "module example.com/fn\n\ngo 1.21\n",
}
for name, content := range files {
if err := os.WriteFile(filepath.Join(tempDir, name), []byte(content), 0o600); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
zipBytes, err := loadPackageSourceArchive(tempDir)
if err != nil {
t.Fatalf("loadPackageSourceArchive error: %v", err)
}
// Verify it's a valid zip
reader, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes)))
if err != nil {
t.Fatalf("invalid zip: %v", err)
}
foundFiles := map[string]bool{}
for _, f := range reader.File {
foundFiles[f.Name] = true
}
if !foundFiles["main.go"] {
t.Fatalf("main.go not found in zip")
}
if !foundFiles["go.mod"] {
t.Fatalf("go.mod not found in zip")
}
}