snapshot before legacy cleanup
This commit is contained in:
@@ -0,0 +1,59 @@
|
|||||||
|
# Слои работ по мультитенантности
|
||||||
|
|
||||||
|
## Слой 1 — базовая поддержка мультитенантности в Fission
|
||||||
|
|
||||||
|
Это внутренний фундамент.
|
||||||
|
|
||||||
|
Простыми словами:
|
||||||
|
сам Fission научили нормально видеть и подхватывать новые namespace-ы в multi-tenant режиме.
|
||||||
|
|
||||||
|
Что это значит:
|
||||||
|
|
||||||
|
1. Новый namespace можно подключать динамически.
|
||||||
|
2. Не нужно копировать одну и ту же watcher-логику в разные части Fission.
|
||||||
|
3. Внутренняя логика namespace lifecycle приведена к общему виду.
|
||||||
|
|
||||||
|
Ещё проще:
|
||||||
|
|
||||||
|
- слой 1 = научили систему работать
|
||||||
|
|
||||||
|
## Слой 1.5 — доводка надёжности мультитенантности
|
||||||
|
|
||||||
|
Это не про новый функционал, а про устойчивость.
|
||||||
|
|
||||||
|
Простыми словами:
|
||||||
|
нужно убедиться, что мультитенантность не глючит в сложных сценариях.
|
||||||
|
|
||||||
|
Что сюда относится:
|
||||||
|
|
||||||
|
1. Проверка гонок и рассинхрона между компонентами.
|
||||||
|
2. Проверка повторных событий и странных watcher-сценариев.
|
||||||
|
3. Проверка удаления namespace-ов.
|
||||||
|
4. Проверка стабильности под нагрузкой и в краевых случаях.
|
||||||
|
|
||||||
|
Ещё проще:
|
||||||
|
|
||||||
|
- слой 1.5 = делаем, чтобы работало надёжно
|
||||||
|
|
||||||
|
## Слой 2 — внешний API / status / console слой
|
||||||
|
|
||||||
|
Это уже внешний уровень поверх готового внутреннего foundation.
|
||||||
|
|
||||||
|
Простыми словами:
|
||||||
|
нужно вывести состояние мультитенантности наружу так, чтобы это видел API, console или status/debug endpoint.
|
||||||
|
|
||||||
|
Что сюда относится:
|
||||||
|
|
||||||
|
1. Показать состояние namespace-ов наружу.
|
||||||
|
2. Дать read-only API или status/debug surface.
|
||||||
|
3. Связать console/API с уже готовым внутренним foundation.
|
||||||
|
|
||||||
|
Ещё проще:
|
||||||
|
|
||||||
|
- слой 2 = показываем и используем это снаружи
|
||||||
|
|
||||||
|
## Краткая формула
|
||||||
|
|
||||||
|
- Слой 1: научили работать
|
||||||
|
- Слой 1.5: делаем стабильным
|
||||||
|
- Слой 2: выводим наружу в API и console
|
||||||
Executable
+62
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SSH_ARGS=(
|
||||||
|
-i ~/.ssh/naeel_vm_id_ed25519
|
||||||
|
-o StrictHostKeyChecking=no
|
||||||
|
-o ConnectTimeout=10
|
||||||
|
naeel@5.172.178.213
|
||||||
|
)
|
||||||
|
|
||||||
|
kube_exec() {
|
||||||
|
if [ "${KUBECTL_DIRECT:-0}" = "1" ]; then
|
||||||
|
kubectl "$@"
|
||||||
|
else
|
||||||
|
ssh "${SSH_ARGS[@]}" "kubectl $*"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace_for_sub() {
|
||||||
|
printf '%s' "$1" | sha256sum | awk '{print "fission-" substr($1, 1, 16)}'
|
||||||
|
}
|
||||||
|
|
||||||
|
delete_ns() {
|
||||||
|
local ns="$1"
|
||||||
|
[ -n "$ns" ] || return 0
|
||||||
|
if [ "${KUBECTL_DIRECT:-0}" = "1" ]; then
|
||||||
|
kubectl delete namespace "$ns" --wait=false >/dev/null 2>&1 || true
|
||||||
|
else
|
||||||
|
ssh "${SSH_ARGS[@]}" "kubectl delete namespace ${ns} --wait=false >/dev/null 2>&1 || true"
|
||||||
|
fi
|
||||||
|
echo "cleanup namespace: ${ns}"
|
||||||
|
}
|
||||||
|
|
||||||
|
delete_all_console_managed() {
|
||||||
|
local ns_list
|
||||||
|
if [ "${KUBECTL_DIRECT:-0}" = "1" ]; then
|
||||||
|
ns_list=$(kubectl get ns -l managed-by=fission-console -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' 2>/dev/null || true)
|
||||||
|
else
|
||||||
|
ns_list=$(ssh "${SSH_ARGS[@]}" '
|
||||||
|
set -e
|
||||||
|
kubectl get ns -l managed-by=fission-console -o jsonpath="{range .items[*]}{.metadata.name}{"\n"}{end}" 2>/dev/null || true
|
||||||
|
' )
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$ns_list" | while IFS= read -r ns; do
|
||||||
|
[ -n "$ns" ] || continue
|
||||||
|
delete_ns "$ns"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$#" -eq 0 ]; then
|
||||||
|
echo "usage: $0 SUB [SUB ...] | --all-console-managed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$1" = "--all-console-managed" ]; then
|
||||||
|
delete_all_console_managed
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "$@" | awk 'NF && !seen[$0]++' | while IFS= read -r sub; do
|
||||||
|
delete_ns "$(namespace_for_sub "$sub")"
|
||||||
|
done
|
||||||
+1
-1
@@ -6,7 +6,7 @@ COPY . .
|
|||||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o fission-console ./cmd/server/
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o fission-console ./cmd/server/
|
||||||
|
|
||||||
FROM alpine:3.20
|
FROM alpine:3.20
|
||||||
RUN apk add --no-cache ca-certificates nodejs python3 ruby perl php83
|
RUN apk add --no-cache ca-certificates nodejs python3 ruby php83
|
||||||
COPY --from=builder /build/fission-console /fission-console
|
COPY --from=builder /build/fission-console /fission-console
|
||||||
EXPOSE 8090
|
EXPOSE 8090
|
||||||
ENTRYPOINT ["/fission-console"]
|
ENTRYPOINT ["/fission-console"]
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import (
|
|||||||
//
|
//
|
||||||
// Почему реальные линтеры, а не LLM:
|
// Почему реальные линтеры, а не LLM:
|
||||||
// LLM часто "исправляет" валидный код и врёт о наличии ошибок.
|
// LLM часто "исправляет" валидный код и врёт о наличии ошибок.
|
||||||
// node --check / python3 -m py_compile / ruby -c / php -l / perl -c — детерминированы и надёжны.
|
// node --check / python3 -m py_compile / ruby -c / php -l — детерминированы и надёжны.
|
||||||
// Go использует go/parser прямо в процессе — без внешних команд, быстро.
|
// Go использует go/parser прямо в процессе — без внешних команд, быстро.
|
||||||
func (s *Server) handleAICheck(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAICheck(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
@@ -54,7 +54,6 @@ func (s *Server) handleAICheck(w http.ResponseWriter, r *http.Request) {
|
|||||||
"python": {ext: ".py", cmd: []string{"python3", "-m", "py_compile"}},
|
"python": {ext: ".py", cmd: []string{"python3", "-m", "py_compile"}},
|
||||||
"ruby": {ext: ".rb", cmd: []string{"ruby", "-c"}},
|
"ruby": {ext: ".rb", cmd: []string{"ruby", "-c"}},
|
||||||
"php": {ext: ".php", cmd: []string{"php", "-l"}},
|
"php": {ext: ".php", cmd: []string{"php", "-l"}},
|
||||||
"perl": {ext: ".pl", cmd: []string{"perl", "-c"}},
|
|
||||||
"go": {ext: ".go", cmd: nil}, // go проверяется через go/parser в процессе
|
"go": {ext: ".go", cmd: nil}, // go проверяется через go/parser в процессе
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,19 @@ var validFuncName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
|
|||||||
// Выше — не имеет смысла для inline функции; лучше использовать Package с URL.
|
// Выше — не имеет смысла для inline функции; лучше использовать Package с URL.
|
||||||
const maxCodeSize = 1 << 20
|
const maxCodeSize = 1 << 20
|
||||||
|
|
||||||
|
func buildDeployArchive(lang, code string) ([]byte, error) {
|
||||||
|
switch lang {
|
||||||
|
case "nodejs":
|
||||||
|
return runtime.BuildJSDeployZip(code)
|
||||||
|
case "php":
|
||||||
|
return runtime.BuildScriptZip(code, "main.php")
|
||||||
|
case "ruby":
|
||||||
|
return runtime.BuildScriptZip(code, "handler.rb")
|
||||||
|
default:
|
||||||
|
return []byte(code), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// handleFunctionsRoot обрабатывает запросы к /console/api/functions без имени функции.
|
// handleFunctionsRoot обрабатывает запросы к /console/api/functions без имени функции.
|
||||||
// GET → список всех функций, POST → создать новую.
|
// GET → список всех функций, POST → создать новую.
|
||||||
func (s *Server) handleFunctionsRoot(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleFunctionsRoot(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -212,17 +225,10 @@ func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
|
|||||||
"buildcommand": "build",
|
"buildcommand": "build",
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
var deployBytes []byte
|
deployBytes, archiveErr := buildDeployArchive(req.Language, req.Code)
|
||||||
if req.Language == "nodejs" {
|
if archiveErr != nil {
|
||||||
zipBytes, zipErr := runtime.BuildJSDeployZip(req.Code)
|
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", req.Language, archiveErr))
|
||||||
if zipErr != nil {
|
return
|
||||||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build nodejs archive: %v", zipErr))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
deployBytes = zipBytes
|
|
||||||
} else {
|
|
||||||
// Python, PHP, Ruby, Perl — код передаётся как есть в deployment.literal
|
|
||||||
deployBytes = []byte(req.Code)
|
|
||||||
}
|
}
|
||||||
pkgSpec = map[string]any{
|
pkgSpec = map[string]any{
|
||||||
"deployment": map[string]any{"type": "literal", "literal": base64.StdEncoding.EncodeToString(deployBytes)},
|
"deployment": map[string]any{"type": "literal", "literal": base64.StdEncoding.EncodeToString(deployBytes)},
|
||||||
@@ -429,16 +435,10 @@ func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request
|
|||||||
|
|
||||||
// Определяем язык из аннотации — нужен для правильной упаковки
|
// Определяем язык из аннотации — нужен для правильной упаковки
|
||||||
lang, _, _ := unstructured.NestedString(fn.Object, "metadata", "annotations", "fission-console/language")
|
lang, _, _ := unstructured.NestedString(fn.Object, "metadata", "annotations", "fission-console/language")
|
||||||
var deployBytes []byte
|
deployBytes, archiveErr := buildDeployArchive(lang, req.Code)
|
||||||
if lang == "nodejs" {
|
if archiveErr != nil {
|
||||||
zipBytes, zipErr := runtime.BuildJSDeployZip(req.Code)
|
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", lang, archiveErr))
|
||||||
if zipErr != nil {
|
return
|
||||||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build nodejs archive: %v", zipErr))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
deployBytes = zipBytes
|
|
||||||
} else {
|
|
||||||
deployBytes = []byte(req.Code)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
literal := base64.StdEncoding.EncodeToString(deployBytes)
|
literal := base64.StdEncoding.EncodeToString(deployBytes)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fission-console/internal/fission"
|
"fission-console/internal/fission"
|
||||||
@@ -14,72 +13,95 @@ import (
|
|||||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var requiredBootstrapServiceAccounts = []string{"fission-fetcher", "fission-builder"}
|
||||||
|
|
||||||
|
var requiredBootstrapRoleBindings = []string{
|
||||||
|
"fission-executor-user-ns",
|
||||||
|
"fission-router-user-ns",
|
||||||
|
"fission-buildermgr-user-ns",
|
||||||
|
"fission-kubewatcher-user-ns",
|
||||||
|
"fission-timer-user-ns",
|
||||||
|
"fission-fetcher-system-user-ns",
|
||||||
|
"fission-builder-system-user-ns",
|
||||||
|
"fission-fetcher-local-user-ns",
|
||||||
|
"fission-builder-local-user-ns",
|
||||||
|
}
|
||||||
|
|
||||||
|
type namespacePreparationStatus struct {
|
||||||
|
ManagedByConsole bool `json:"managedByConsole"`
|
||||||
|
WatcherLabel bool `json:"watcherLabel"`
|
||||||
|
ServiceAccounts map[string]bool `json:"serviceAccounts"`
|
||||||
|
RoleBindings map[string]bool `json:"roleBindings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s namespacePreparationStatus) Ready() bool {
|
||||||
|
if !s.ManagedByConsole || !s.WatcherLabel {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, ok := range s.ServiceAccounts {
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, ok := range s.RoleBindings {
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type controlPlaneServiceStatus struct {
|
||||||
|
Ready bool `json:"ready"`
|
||||||
|
PodCount int `json:"podCount"`
|
||||||
|
ReadyPods []string `json:"readyPods"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type controlPlaneStatus struct {
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
Ready bool `json:"ready"`
|
||||||
|
Executor controlPlaneServiceStatus `json:"executor"`
|
||||||
|
Router controlPlaneServiceStatus `json:"router"`
|
||||||
|
}
|
||||||
|
|
||||||
// handleNSStatus возвращает статус инициализации пользовательского namespace.
|
// handleNSStatus возвращает статус инициализации пользовательского namespace.
|
||||||
// Используется UI для отображения прогресса при первом входе нового пользователя.
|
// Используется UI для отображения прогресса при первом входе нового пользователя.
|
||||||
//
|
//
|
||||||
// Три стадии:
|
// Три стадии:
|
||||||
// 1. Namespace существует и Active
|
// 1. Namespace существует и Active
|
||||||
// 2. Namespace добавлен в FISSION_RESOURCE_NAMESPACES executor deployment
|
// 2. Namespace подготовлен для Layer 1 NSWatcher: label + bootstrap RBAC/SA
|
||||||
// 3. Executor pod Running + Ready (готов принимать функции)
|
// 3. Fission control-plane готов: executor и router Running + Ready
|
||||||
func (s *Server) handleNSStatus(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleNSStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
ns := s.userNS(r)
|
ns := s.userNS(r)
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
fissionNS := os.Getenv("FISSION_SYSTEM_NAMESPACE")
|
|
||||||
if fissionNS == "" {
|
|
||||||
fissionNS = "fission"
|
|
||||||
}
|
|
||||||
|
|
||||||
type stageInfo struct {
|
type stageInfo struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Done bool `json:"done"`
|
Done bool `json:"done"`
|
||||||
}
|
}
|
||||||
stages := []stageInfo{
|
stages := []stageInfo{
|
||||||
{Name: "Создание пространства имён"},
|
{Name: "Создание пространства имён"},
|
||||||
{Name: "Регистрация в Fission"},
|
{Name: "Подготовка namespace для Fission"},
|
||||||
{Name: "Прогрев окружений"},
|
{Name: "Готовность control-plane"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stage 1: namespace существует и Active
|
|
||||||
nsObj, err := s.dyn.Resource(fission.NamespaceGVR).Get(ctx, ns, metav1.GetOptions{})
|
nsObj, err := s.dyn.Resource(fission.NamespaceGVR).Get(ctx, ns, metav1.GetOptions{})
|
||||||
|
var preparation namespacePreparationStatus
|
||||||
|
control := controlPlaneStatus{Namespace: fissionSystemNamespace()}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
phase, _, _ := unstructured.NestedString(nsObj.Object, "status", "phase")
|
phase, _, _ := unstructured.NestedString(nsObj.Object, "status", "phase")
|
||||||
stages[0].Done = phase == "Active"
|
stages[0].Done = phase == "Active"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stage 2: namespace в FISSION_RESOURCE_NAMESPACES executor deployment
|
|
||||||
if stages[0].Done {
|
if stages[0].Done {
|
||||||
execDep, err2 := s.dyn.Resource(fission.DeploymentGVR).Namespace(fissionNS).Get(ctx, "executor", metav1.GetOptions{})
|
preparation = s.namespacePreparationStatus(ctx, ns, nsObj)
|
||||||
if err2 == nil {
|
stages[1].Done = preparation.Ready()
|
||||||
stages[1].Done = nsInFissionEnv(execDep, ns)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stage 3: executor pod Running + Ready
|
|
||||||
if stages[1].Done {
|
if stages[1].Done {
|
||||||
podGVR := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}
|
control = s.controlPlaneStatus(ctx)
|
||||||
podList, err3 := s.dyn.Resource(podGVR).Namespace(fissionNS).List(ctx, metav1.ListOptions{
|
stages[2].Done = control.Ready
|
||||||
LabelSelector: "svc=executor",
|
|
||||||
})
|
|
||||||
if err3 == nil {
|
|
||||||
for _, pod := range podList.Items {
|
|
||||||
phase, _, _ := unstructured.NestedString(pod.Object, "status", "phase")
|
|
||||||
if phase != "Running" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
conditions, _, _ := unstructured.NestedSlice(pod.Object, "status", "conditions")
|
|
||||||
for _, c := range conditions {
|
|
||||||
cond, ok := c.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if cond["type"] == "Ready" && cond["status"] == "True" {
|
|
||||||
stages[2].Done = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ready := stages[0].Done && stages[1].Done && stages[2].Done
|
ready := stages[0].Done && stages[1].Done && stages[2].Done
|
||||||
@@ -89,32 +111,116 @@ func (s *Server) handleNSStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// nsInFissionEnv проверяет что namespace ns содержится в FISSION_RESOURCE_NAMESPACES
|
func (s *Server) handleNSDebug(w http.ResponseWriter, r *http.Request) {
|
||||||
// первого контейнера данного deployment.
|
ns := s.userNS(r)
|
||||||
func nsInFissionEnv(dep *unstructured.Unstructured, ns string) bool {
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
containers, _, _ := unstructured.NestedSlice(dep.Object, "spec", "template", "spec", "containers")
|
defer cancel()
|
||||||
for _, c := range containers {
|
|
||||||
cont, ok := c.(map[string]any)
|
nsObj, err := s.dyn.Resource(fission.NamespaceGVR).Get(ctx, ns, metav1.GetOptions{})
|
||||||
|
labels := map[string]string{}
|
||||||
|
phase := ""
|
||||||
|
exists := err == nil
|
||||||
|
active := false
|
||||||
|
preparation := namespacePreparationStatus{}
|
||||||
|
if exists {
|
||||||
|
labels, _, _ = unstructured.NestedStringMap(nsObj.Object, "metadata", "labels")
|
||||||
|
phase, _, _ = unstructured.NestedString(nsObj.Object, "status", "phase")
|
||||||
|
active = phase == "Active"
|
||||||
|
preparation = s.namespacePreparationStatus(ctx, ns, nsObj)
|
||||||
|
}
|
||||||
|
control := s.controlPlaneStatus(ctx)
|
||||||
|
|
||||||
|
writeAnyJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"namespace": ns,
|
||||||
|
"exists": exists,
|
||||||
|
"active": active,
|
||||||
|
"phase": phase,
|
||||||
|
"labels": labels,
|
||||||
|
"prepared": preparation.Ready(),
|
||||||
|
"preparation": preparation,
|
||||||
|
"controlPlane": control,
|
||||||
|
"namespaceReady": active && preparation.Ready() && control.Ready,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) namespacePreparationStatus(ctx context.Context, ns string, nsObj *unstructured.Unstructured) namespacePreparationStatus {
|
||||||
|
status := namespacePreparationStatus{
|
||||||
|
ServiceAccounts: make(map[string]bool, len(requiredBootstrapServiceAccounts)),
|
||||||
|
RoleBindings: make(map[string]bool, len(requiredBootstrapRoleBindings)),
|
||||||
|
}
|
||||||
|
if nsObj != nil {
|
||||||
|
labels, _, _ := unstructured.NestedStringMap(nsObj.Object, "metadata", "labels")
|
||||||
|
status.ManagedByConsole = labels["managed-by"] == "fission-console"
|
||||||
|
status.WatcherLabel = labels["fission.io/managed"] == "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
saGVR := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "serviceaccounts"}
|
||||||
|
for _, name := range requiredBootstrapServiceAccounts {
|
||||||
|
_, err := s.dyn.Resource(saGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
|
||||||
|
status.ServiceAccounts[name] = err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rbGVR := schema.GroupVersionResource{Group: "rbac.authorization.k8s.io", Version: "v1", Resource: "rolebindings"}
|
||||||
|
for _, name := range requiredBootstrapRoleBindings {
|
||||||
|
_, err := s.dyn.Resource(rbGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
|
||||||
|
status.RoleBindings[name] = err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) controlPlaneStatus(ctx context.Context) controlPlaneStatus {
|
||||||
|
status := controlPlaneStatus{Namespace: fissionSystemNamespace()}
|
||||||
|
status.Executor = s.serviceStatus(ctx, status.Namespace, "executor")
|
||||||
|
status.Router = s.serviceStatus(ctx, status.Namespace, "router")
|
||||||
|
status.Ready = status.Executor.Ready && status.Router.Ready
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) serviceStatus(ctx context.Context, namespace, service string) controlPlaneServiceStatus {
|
||||||
|
podGVR := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}
|
||||||
|
podList, err := s.dyn.Resource(podGVR).Namespace(namespace).List(ctx, metav1.ListOptions{
|
||||||
|
LabelSelector: "svc=" + service,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return controlPlaneServiceStatus{}
|
||||||
|
}
|
||||||
|
|
||||||
|
status := controlPlaneServiceStatus{PodCount: len(podList.Items)}
|
||||||
|
for _, pod := range podList.Items {
|
||||||
|
if podReady(&pod) {
|
||||||
|
status.Ready = true
|
||||||
|
status.ReadyPods = append(status.ReadyPods, pod.GetName())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
func fissionSystemNamespace() string {
|
||||||
|
ns := os.Getenv("FISSION_SYSTEM_NAMESPACE")
|
||||||
|
if ns == "" {
|
||||||
|
return "fission"
|
||||||
|
}
|
||||||
|
return ns
|
||||||
|
}
|
||||||
|
|
||||||
|
func podReady(pod *unstructured.Unstructured) bool {
|
||||||
|
if pod == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
phase, _, _ := unstructured.NestedString(pod.Object, "status", "phase")
|
||||||
|
if phase != "Running" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
conditions, _, _ := unstructured.NestedSlice(pod.Object, "status", "conditions")
|
||||||
|
for _, c := range conditions {
|
||||||
|
cond, ok := c.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
envs, _, _ := unstructured.NestedSlice(cont, "env")
|
if cond["type"] == "Ready" && cond["status"] == "True" {
|
||||||
for _, e := range envs {
|
return true
|
||||||
env, ok := e.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if env["name"] == "FISSION_RESOURCE_NAMESPACES" {
|
|
||||||
if v, ok := env["value"].(string); ok {
|
|
||||||
for _, p := range strings.Split(v, ",") {
|
|
||||||
if strings.TrimSpace(p) == ns {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break // только первый контейнер
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -90,7 +90,7 @@ func decodeArchiveBytesToSource(decoded []byte) (string, error) {
|
|||||||
if len(decoded) == 0 {
|
if len(decoded) == 0 {
|
||||||
return "", io.ErrUnexpectedEOF
|
return "", io.ErrUnexpectedEOF
|
||||||
}
|
}
|
||||||
// Если байты — валидный UTF-8, возвращаем напрямую (python, ruby, perl, php)
|
// Если байты — валидный UTF-8, возвращаем напрямую (python, ruby, php)
|
||||||
if utf8.Valid(decoded) {
|
if utf8.Valid(decoded) {
|
||||||
return string(decoded), nil
|
return string(decoded), nil
|
||||||
}
|
}
|
||||||
@@ -112,7 +112,7 @@ func decodeZipSource(zipBytes []byte) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Сначала ищем по приоритетным именам
|
// Сначала ищем по приоритетным именам
|
||||||
preferred := []string{"main.py", "main.js", "main.go", "handler.go", "handler.js", "handler.py"}
|
preferred := []string{"main.py", "main.js", "main.go", "main.php", "handler.rb", "handler.pl", "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) {
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/console/api/httptriggers", auth(s.handleList(fission.HTTPTrigGVR)))
|
mux.HandleFunc("/console/api/httptriggers", auth(s.handleList(fission.HTTPTrigGVR)))
|
||||||
mux.HandleFunc("/console/api/timetriggers", auth(s.handleList(fission.TimeTrigGVR)))
|
mux.HandleFunc("/console/api/timetriggers", auth(s.handleList(fission.TimeTrigGVR)))
|
||||||
mux.HandleFunc("/console/api/ns/status", auth(s.handleNSStatus))
|
mux.HandleFunc("/console/api/ns/status", auth(s.handleNSStatus))
|
||||||
|
mux.HandleFunc("/console/api/ns/debug", auth(s.handleNSDebug))
|
||||||
mux.HandleFunc("/console/api/ai/check", auth(s.handleAICheck))
|
mux.HandleFunc("/console/api/ai/check", auth(s.handleAICheck))
|
||||||
// --- ai/ask feature (удалить строку чтобы выкосить роут) ---
|
// --- ai/ask feature (удалить строку чтобы выкосить роут) ---
|
||||||
mux.HandleFunc("/console/api/ai/ask", auth(s.handleAIAsk))
|
mux.HandleFunc("/console/api/ai/ask", auth(s.handleAIAsk))
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ func CleanupEnvironmentIfUnused(ctx context.Context, dyn dynamic.Interface, ns,
|
|||||||
// Вся Fission-специфичная схема изолирована здесь — при обновлении Fission меняем только тут.
|
// Вся Fission-специфичная схема изолирована здесь — при обновлении Fission меняем только тут.
|
||||||
func buildLangEnvironment(name, ns string, def model.LangEnvDef) *unstructured.Unstructured {
|
func buildLangEnvironment(name, ns string, def model.LangEnvDef) *unstructured.Unstructured {
|
||||||
// Version по умолчанию 3 (V2 protocol с async entrypoint).
|
// Version по умолчанию 3 (V2 protocol с async entrypoint).
|
||||||
// Исключение: perl-env поддерживает только V1 protocol → version=1.
|
|
||||||
envVersion := int64(3)
|
envVersion := int64(3)
|
||||||
if def.Version != 0 {
|
if def.Version != 0 {
|
||||||
envVersion = int64(def.Version)
|
envVersion = int64(def.Version)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type UpdateCodeRequest struct {
|
|||||||
|
|
||||||
// LangEnvDef описывает Docker-образы для конкретного языка.
|
// LangEnvDef описывает Docker-образы для конкретного языка.
|
||||||
// BuilderImage заполнен только для языков которым нужна компиляция (Go).
|
// BuilderImage заполнен только для языков которым нужна компиляция (Go).
|
||||||
// Version: версия среды Fission (по умолчанию 3; perl использует 1 — нет поддержки async entrypoint).
|
// Version: версия среды Fission (по умолчанию 3).
|
||||||
type LangEnvDef struct {
|
type LangEnvDef struct {
|
||||||
Image string
|
Image string
|
||||||
BuilderImage string
|
BuilderImage string
|
||||||
@@ -32,15 +32,10 @@ type LangEnvDef struct {
|
|||||||
|
|
||||||
// LangEnvMap сопоставляет идентификатор языка (string) с описанием среды выполнения.
|
// LangEnvMap сопоставляет идентификатор языка (string) с описанием среды выполнения.
|
||||||
// Ключ используется в createFunctionRequest.Language и как суффикс имени Environment.
|
// Ключ используется в createFunctionRequest.Language и как суффикс имени Environment.
|
||||||
//
|
|
||||||
// Почему perl Version=1:
|
|
||||||
// Fission Environment v3 требует поддержки async entrypoint (V2 protocol).
|
|
||||||
// ghcr.io/fission/perl-env поддерживает только V1 protocol → version=1.
|
|
||||||
var LangEnvMap = map[string]LangEnvDef{
|
var LangEnvMap = map[string]LangEnvDef{
|
||||||
"python": {Image: "ghcr.io/fission/python-env"},
|
"python": {Image: "ghcr.io/fission/python-env"},
|
||||||
"nodejs": {Image: "ghcr.io/fission/node-env"},
|
"nodejs": {Image: "ghcr.io/fission/node-env"},
|
||||||
"go": {Image: "ghcr.io/fission/go-env", BuilderImage: "naeel/go-builder-fast:v1"},
|
"go": {Image: "ghcr.io/fission/go-env", BuilderImage: "naeel/go-builder-fast:v1"},
|
||||||
"php": {Image: "ghcr.io/fission/php-env"},
|
"php": {Image: "ghcr.io/fission/php-env"},
|
||||||
"ruby": {Image: "ghcr.io/fission/ruby-env"},
|
"ruby": {Image: "ghcr.io/fission/ruby-env"},
|
||||||
"perl": {Image: "ghcr.io/fission/perl-env", Version: 1},
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ package runtime
|
|||||||
// - nodejs: "main" → имя экспортированной default-функции в main.js
|
// - nodejs: "main" → имя экспортированной default-функции в main.js
|
||||||
// - php: "main.php::handler" → имя файла + "::" + имя функции
|
// - php: "main.php::handler" → имя файла + "::" + имя функции
|
||||||
// - ruby: "handler" → имя метода в загруженном файле
|
// - ruby: "handler" → имя метода в загруженном файле
|
||||||
// - perl: "handler" → имя функции в загруженном файле
|
|
||||||
// - go: "Handler" → экспортированная Go-функция (с большой буквы)
|
// - go: "Handler" → экспортированная Go-функция (с большой буквы)
|
||||||
func DefaultEntrypoint(lang string) string {
|
func DefaultEntrypoint(lang string) string {
|
||||||
switch lang {
|
switch lang {
|
||||||
@@ -18,8 +17,6 @@ func DefaultEntrypoint(lang string) string {
|
|||||||
return "main.php::handler"
|
return "main.php::handler"
|
||||||
case "ruby":
|
case "ruby":
|
||||||
return "handler"
|
return "handler"
|
||||||
case "perl":
|
|
||||||
return "handler"
|
|
||||||
case "go":
|
case "go":
|
||||||
// Go: экспортированная функция (заглавная) — go/plugin требует экспорт
|
// Go: экспортированная функция (заглавная) — go/plugin требует экспорт
|
||||||
return "Handler"
|
return "Handler"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// buildZip создаёт zip-архив с одним файлом fileName и содержимым content.
|
// buildZip создаёт zip-архив с одним файлом fileName и содержимым content.
|
||||||
// Вспомогательная функция: используется в buildScriptZip (PHP/Ruby/Perl),
|
// Вспомогательная функция: используется в buildScriptZip (PHP/Ruby),
|
||||||
// а также как основа для buildGoSourceZip и buildJSDeployZip.
|
// а также как основа для buildGoSourceZip и buildJSDeployZip.
|
||||||
func buildZip(fileName string, content []byte) ([]byte, error) {
|
func buildZip(fileName string, content []byte) ([]byte, error) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ import (
|
|||||||
// Результат: zip с двумя файлами:
|
// Результат: zip с двумя файлами:
|
||||||
// - package.json: {"type":"module"}
|
// - package.json: {"type":"module"}
|
||||||
// - main.js: ESM wrapper + инлайн пользовательский код через new Function
|
// - main.js: ESM wrapper + инлайн пользовательский код через new Function
|
||||||
|
//
|
||||||
|
// Совместимость entrypoint:
|
||||||
|
// UI исторически отправлял entrypoint="handler", а backend по умолчанию использует
|
||||||
|
// entrypoint="main". Чтобы specialization не ломался из-за несовпадения,
|
||||||
|
// wrapper экспортирует обе точки входа: named exports main и handler, а также default.
|
||||||
func BuildJSDeployZip(code string) ([]byte, error) {
|
func BuildJSDeployZip(code string) ([]byte, error) {
|
||||||
// Сериализуем пользовательский код в JSON строку чтобы безопасно инлайнить
|
// Сериализуем пользовательский код в JSON строку чтобы безопасно инлайнить
|
||||||
// в JavaScript-литерал — экранирует кавычки, переводы строк, спецсимволы.
|
// в JavaScript-литерал — экранирует кавычки, переводы строк, спецсимволы.
|
||||||
@@ -38,7 +43,7 @@ func BuildJSDeployZip(code string) ([]byte, error) {
|
|||||||
(new Function('module', 'exports', %s))(__mod, __mod.exports);
|
(new Function('module', 'exports', %s))(__mod, __mod.exports);
|
||||||
const _fn = __mod.exports;
|
const _fn = __mod.exports;
|
||||||
|
|
||||||
export default async function(ctx) {
|
async function __invoke(ctx) {
|
||||||
const fn = typeof _fn === 'function' ? _fn : (_fn.default || _fn.handler || _fn.main);
|
const fn = typeof _fn === 'function' ? _fn : (_fn.default || _fn.handler || _fn.main);
|
||||||
if (!fn) throw new Error('no exported function found in user code');
|
if (!fn) throw new Error('no exported function found in user code');
|
||||||
const result = await fn(ctx);
|
const result = await fn(ctx);
|
||||||
@@ -46,6 +51,9 @@ export default async function(ctx) {
|
|||||||
if (typeof result.status !== 'undefined') return result;
|
if (typeof result.status !== 'undefined') return result;
|
||||||
return { status: 200, ...result };
|
return { status: 200, ...result };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { __invoke as main, __invoke as handler };
|
||||||
|
export default __invoke;
|
||||||
`, string(codeJSON))
|
`, string(codeJSON))
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildJSDeployZipExportsMainAndHandler(t *testing.T) {
|
||||||
|
zipBytes, err := BuildJSDeployZip(`module.exports = async function () { return { status: 200, body: "ok" }; }`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildJSDeployZip() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
zr, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("zip.NewReader() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files := map[string]string{}
|
||||||
|
for _, file := range zr.File {
|
||||||
|
rc, openErr := file.Open()
|
||||||
|
if openErr != nil {
|
||||||
|
t.Fatalf("open %q: %v", file.Name, openErr)
|
||||||
|
}
|
||||||
|
content, readErr := io.ReadAll(rc)
|
||||||
|
_ = rc.Close()
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatalf("read %q: %v", file.Name, readErr)
|
||||||
|
}
|
||||||
|
files[file.Name] = string(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
if files["package.json"] != `{"type":"module"}` {
|
||||||
|
t.Fatalf("package.json = %q, want ESM marker", files["package.json"])
|
||||||
|
}
|
||||||
|
|
||||||
|
mainJS := files["main.js"]
|
||||||
|
if !strings.Contains(mainJS, `export { __invoke as main, __invoke as handler };`) {
|
||||||
|
t.Fatalf("main.js does not export both main and handler: %s", mainJS)
|
||||||
|
}
|
||||||
|
if !strings.Contains(mainJS, `export default __invoke;`) {
|
||||||
|
t.Fatalf("main.js does not export default invoke: %s", mainJS)
|
||||||
|
}
|
||||||
|
if !strings.Contains(mainJS, `_fn.default || _fn.handler || _fn.main`) {
|
||||||
|
t.Fatalf("main.js lost user export resolution: %s", mainJS)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package runtime
|
package runtime
|
||||||
|
|
||||||
// BuildScriptZip создаёт zip-архив с одним файлом fileName и содержимым code.
|
// BuildScriptZip создаёт zip-архив с одним файлом fileName и содержимым code.
|
||||||
// Используется для PHP, Ruby, Perl — языков где среда Fission ожидает
|
// Используется для PHP и Ruby — языков где среда Fission ожидает
|
||||||
// именованный файл (handler.rb, handler.pl, main.php и т.д.) внутри архива.
|
// именованный файл (handler.rb, handler.pl, main.php и т.д.) внутри архива.
|
||||||
//
|
//
|
||||||
// Почему zip, а не просто literal:
|
// Почему zip, а не просто literal:
|
||||||
|
|||||||
+1
-5
@@ -125,7 +125,6 @@ var langEnvMap = map[string]langEnvDef{
|
|||||||
"go": {Image: "ghcr.io/fission/go-env", BuilderImage: "naeel/go-builder-fast:v1"},
|
"go": {Image: "ghcr.io/fission/go-env", BuilderImage: "naeel/go-builder-fast:v1"},
|
||||||
"php": {Image: "ghcr.io/fission/php-env"},
|
"php": {Image: "ghcr.io/fission/php-env"},
|
||||||
"ruby": {Image: "ghcr.io/fission/ruby-env"},
|
"ruby": {Image: "ghcr.io/fission/ruby-env"},
|
||||||
"perl": {Image: "ghcr.io/fission/perl-env", Version: 1},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type updateCodeRequest struct {
|
type updateCodeRequest struct {
|
||||||
@@ -862,7 +861,7 @@ export default async function(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildScriptZip wraps code into a zip file with the given filename.
|
// buildScriptZip wraps code into a zip file with the given filename.
|
||||||
// Used for PHP, Ruby, Perl where the environment requires a named source file.
|
// Used for PHP and Ruby where the environment requires a named source file.
|
||||||
func buildScriptZip(code, filename string) ([]byte, error) {
|
func buildScriptZip(code, filename string) ([]byte, error) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
zw := zip.NewWriter(&buf)
|
zw := zip.NewWriter(&buf)
|
||||||
@@ -887,8 +886,6 @@ func defaultEntrypoint(lang string) string {
|
|||||||
return "main.php::handler"
|
return "main.php::handler"
|
||||||
case "ruby":
|
case "ruby":
|
||||||
return "handler"
|
return "handler"
|
||||||
case "perl":
|
|
||||||
return "handler"
|
|
||||||
case "go":
|
case "go":
|
||||||
return "Handler"
|
return "Handler"
|
||||||
default:
|
default:
|
||||||
@@ -2266,7 +2263,6 @@ func (s *server) handleAICheck(w http.ResponseWriter, r *http.Request) {
|
|||||||
"python": {ext: ".py", cmd: []string{"python3", "-m", "py_compile"}},
|
"python": {ext: ".py", cmd: []string{"python3", "-m", "py_compile"}},
|
||||||
"ruby": {ext: ".rb", cmd: []string{"ruby", "-c"}},
|
"ruby": {ext: ".rb", cmd: []string{"ruby", "-c"}},
|
||||||
"php": {ext: ".php", cmd: []string{"php", "-l"}},
|
"php": {ext: ".php", cmd: []string{"php", "-l"}},
|
||||||
"perl": {ext: ".pl", cmd: []string{"perl", "-c"}},
|
|
||||||
"go": {ext: ".go", cmd: nil}, // go проверяем через go/parser
|
"go": {ext: ".go", cmd: nil}, // go проверяем через go/parser
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+305
-88
@@ -1,10 +1,12 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
|
|
||||||
<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>NUBES 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">
|
<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>
|
<script>
|
||||||
if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
|
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);
|
window.location.replace('https://' + window.location.host + window.location.pathname + window.location.search + window.location.hash);
|
||||||
@@ -21,7 +23,11 @@
|
|||||||
--text-secondary: #6b8eaa;
|
--text-secondary: #6b8eaa;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||||
@@ -164,7 +170,8 @@
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
th, td {
|
th,
|
||||||
|
td {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-bottom: 1px solid #0b2a48;
|
border-bottom: 1px solid #0b2a48;
|
||||||
@@ -214,14 +221,16 @@
|
|||||||
.modal {
|
.modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0,0,0,.45);
|
background: rgba(0, 0, 0, .45);
|
||||||
display: none;
|
display: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal.open { display: flex; }
|
.modal.open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
width: min(820px, 100%);
|
width: min(820px, 100%);
|
||||||
@@ -257,7 +266,9 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
input, select, textarea {
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid #1d486d;
|
border: 1px solid #1d486d;
|
||||||
background: #03192c;
|
background: #03192c;
|
||||||
@@ -286,8 +297,10 @@
|
|||||||
}
|
}
|
||||||
</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 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="panel" style="width:min(440px,100%);">
|
||||||
<div class="brand" style="margin-bottom:24px;">
|
<div class="brand" style="margin-bottom:24px;">
|
||||||
<div class="brand-mark">N</div>
|
<div class="brand-mark">N</div>
|
||||||
@@ -298,7 +311,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="row" style="flex-direction:column; gap:6px; margin-bottom:12px;">
|
<div class="row" style="flex-direction:column; gap:6px; margin-bottom:12px;">
|
||||||
<label style="font-size:12px; color:#999;">Стенд</label>
|
<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;">
|
<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="dev">Dev</option>
|
||||||
<option value="test" selected>Test</option>
|
<option value="test" selected>Test</option>
|
||||||
<option value="prod">Prod</option>
|
<option value="prod">Prod</option>
|
||||||
@@ -306,47 +320,89 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="row" style="flex-direction:column; gap:6px; margin-bottom:12px;">
|
<div class="row" style="flex-direction:column; gap:6px; margin-bottom:12px;">
|
||||||
<label style="font-size:12px; color:#999;">Токен</label>
|
<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>
|
<textarea id="l-token" rows="5"
|
||||||
<div style="font-size:11px; color:var(--text-secondary); margin-top:4px;">Токен: <strong style="color:#8bc7ff;">Личный кабинет → Профиль пользователя → Токены</strong></div>
|
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 style="font-size:11px; color:var(--text-secondary); margin-top:4px;">Токен: <strong
|
||||||
|
style="color:#8bc7ff;">Личный кабинет → Профиль пользователя → Токены</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="l-error" style="display:none; color:#ff6b6b; font-size:13px; margin-bottom:10px;"></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>
|
<button id="l-btn" class="btn" style="width:100%;" onclick="doLogin()">Войти</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Overlay: инициализация нового окружения -->
|
<!-- Overlay: инициализация нового окружения -->
|
||||||
<div id="init-overlay" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.88); z-index:1000; align-items:center; justify-content:center; padding:16px;">
|
<div id="init-overlay"
|
||||||
|
style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.88); z-index:1000; align-items:center; justify-content:center; padding:16px;">
|
||||||
<div class="panel" style="width:min(520px,100%);">
|
<div class="panel" style="width:min(520px,100%);">
|
||||||
<div style="margin-bottom:16px;">
|
<div style="margin-bottom:16px;">
|
||||||
<div style="font-size:16px; font-weight:700; margin-bottom:6px;">⚙️ Инициализация окружения</div>
|
<div style="font-size:16px; font-weight:700; margin-bottom:6px;">⚙️ Инициализация окружения</div>
|
||||||
<div style="font-size:13px; color:var(--text-secondary);">Первый вход. Настраиваем ваше окружение — это займёт ~5 минут.</div>
|
<div style="font-size:13px; color:var(--text-secondary);">Первый вход. Настраиваем ваше окружение — это займёт
|
||||||
|
~5 минут.</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; margin-bottom:12px;">
|
||||||
|
<div style="background:#0d1827; border:1px solid var(--border); border-radius:6px; padding:10px;">
|
||||||
|
<div style="font-size:11px; color:var(--text-secondary); margin-bottom:4px;">Время настройки</div>
|
||||||
|
<div id="init-elapsed" style="font-size:18px; font-weight:700; color:#8bc7ff;">0с</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:#0d1827; border:1px solid var(--border); border-radius:6px; padding:10px;">
|
||||||
|
<div style="font-size:11px; color:var(--text-secondary); margin-bottom:4px;">Текущий этап</div>
|
||||||
|
<div id="init-current-stage" style="font-size:14px; font-weight:600; color:#f4f7fb;">Ожидание старта</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="init-summary"
|
||||||
|
style="display:none; margin-bottom:10px; background:#101c2b; border:1px solid var(--border); border-radius:6px; padding:10px 12px; font-size:13px; color:#d8e5f2;">
|
||||||
|
</div>
|
||||||
|
<div id="init-reason"
|
||||||
|
style="display:none; margin-bottom:10px; background:#2d2310; border:1px solid #6a5427; border-radius:6px; padding:10px 12px; font-size:13px; color:#ffd98a;">
|
||||||
|
</div>
|
||||||
|
<div id="init-error"
|
||||||
|
style="display:none; margin-bottom:10px; background:#2a1416; border:1px solid #7d3138; border-radius:6px; padding:10px 12px; font-size:13px; color:#ff9da5;">
|
||||||
|
</div>
|
||||||
|
<div id="init-log"
|
||||||
|
style="background:#000d1a; border:1px solid var(--border); border-radius:6px; padding:12px; font-family:monospace; font-size:12px; height:160px; overflow-y:auto; color:#4fc3f7; white-space:pre-wrap; line-height:1.6;">
|
||||||
</div>
|
</div>
|
||||||
<div id="init-log" style="background:#000d1a; border:1px solid var(--border); border-radius:6px; padding:12px; font-family:monospace; font-size:12px; height:160px; overflow-y:auto; color:#4fc3f7; white-space:pre-wrap; line-height:1.6;"></div>
|
|
||||||
<div id="init-stages" style="margin-top:14px; display:flex; flex-direction:column; gap:8px;"></div>
|
<div id="init-stages" style="margin-top:14px; display:flex; flex-direction:column; gap:8px;"></div>
|
||||||
<div style="margin-top:14px; font-size:12px; color:var(--text-secondary);">Страница обновится автоматически когда всё будет готово.</div>
|
<div style="margin-top:14px; font-size:12px; color:var(--text-secondary);">Страница обновится автоматически когда
|
||||||
|
всё будет готово.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="navbar">
|
<div class="navbar">
|
||||||
<div class="brand-mark">N</div>
|
<div class="brand-mark">N</div>
|
||||||
<div class="brand-text">
|
<div class="brand-text">
|
||||||
<div class="nubes">NUBES</div>
|
<div class="nubes">NUBES</div>
|
||||||
<div class="product">FISSION CONSOLE</div>
|
<div class="product">FISSION CONSOLE</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row" style="margin:0;">
|
|
||||||
<button class="btn ghost" onclick="reloadAll()">Refresh</button>
|
|
||||||
<button class="btn" onclick="openCreate()">+ Создать функцию</button>
|
|
||||||
<button class="btn ghost" onclick="doLogout()" style="margin-left:8px;">Выход</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row" style="margin:0;">
|
||||||
|
<button class="btn ghost" onclick="reloadAll()">Refresh</button>
|
||||||
|
<button class="btn" onclick="openCreate()">+ Создать функцию</button>
|
||||||
|
<button class="btn ghost" onclick="doLogout()" style="margin-left:8px;">Выход</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="card"><div class="k">Окружения</div><div id="env-count" class="v">-</div></div>
|
<div class="card">
|
||||||
<div class="card"><div class="k">Пакеты</div><div id="pkg-count" class="v">-</div></div>
|
<div class="k">Окружения</div>
|
||||||
<div class="card"><div class="k">Функции</div><div id="fn-count" class="v">-</div></div>
|
<div id="env-count" class="v">-</div>
|
||||||
<div class="card"><div class="k">HTTP-триггеры</div><div id="http-count" class="v">-</div></div>
|
</div>
|
||||||
<div class="card"><div class="k">Тайм-триггеры</div><div id="time-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">Функции</div>
|
||||||
|
<div id="fn-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">Тайм-триггеры</div>
|
||||||
|
<div id="time-count" class="v">-</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="box">
|
<div class="box">
|
||||||
@@ -356,7 +412,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Имя</th><th>Окружение</th><th>Пакет</th><th>Маршрут</th><th>Методы</th><th class="nowrap">Действия</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>
|
||||||
@@ -380,7 +443,6 @@
|
|||||||
<option value="nodejs">Node.js</option>
|
<option value="nodejs">Node.js</option>
|
||||||
<option value="php">PHP</option>
|
<option value="php">PHP</option>
|
||||||
<option value="ruby">Ruby</option>
|
<option value="ruby">Ruby</option>
|
||||||
<option value="perl">Perl</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -404,18 +466,22 @@
|
|||||||
return {"ok": True, "msg": "hello from fission console"}
|
return {"ok": True, "msg": "hello from fission console"}
|
||||||
</textarea>
|
</textarea>
|
||||||
<div style="margin-top:6px; display:flex; gap:6px; flex-wrap:wrap;">
|
<div style="margin-top:6px; display:flex; gap:6px; flex-wrap:wrap;">
|
||||||
<button class="btn ghost" id="c-ai-btn" onclick="aiCheck('c-code','c-lang','c-ai-result')">🔍 Проверить синтаксис</button>
|
<button class="btn ghost" id="c-ai-btn" onclick="aiCheck('c-code','c-lang','c-ai-result')">🔍 Проверить
|
||||||
|
синтаксис</button>
|
||||||
<button class="btn ghost" id="c-gen-btn" onclick="showGenPrompt()">✨ Сгенерировать код</button>
|
<button class="btn ghost" id="c-gen-btn" onclick="showGenPrompt()">✨ Сгенерировать код</button>
|
||||||
<button class="btn ghost" id="c-exp-btn" onclick="aiExplain('c-code','c-lang','c-ai-result')">📖 Что делает?</button>
|
<button class="btn ghost" id="c-exp-btn" onclick="aiExplain('c-code','c-lang','c-ai-result')">📖 Что
|
||||||
|
делает?</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="c-gen-prompt" style="display:none; margin-top:8px; display:none; gap:6px; align-items:center;">
|
<div id="c-gen-prompt" style="display:none; margin-top:8px; display:none; gap:6px; align-items:center;">
|
||||||
<input id="c-gen-desc" type="text" placeholder="Что должна делать функция? (напр: принять JSON, вернуть сумму чисел)"
|
<input id="c-gen-desc" type="text"
|
||||||
style="flex:1; background:#2a2a42; border:1px solid #3a3a5c; border-radius:4px;
|
placeholder="Что должна делать функция? (напр: принять JSON, вернуть сумму чисел)" style="flex:1; background:#2a2a42; border:1px solid #3a3a5c; border-radius:4px;
|
||||||
color:#cdd6f4; padding:6px 8px; font-size:.82rem; outline:none; width:100%;"
|
color:#cdd6f4; padding:6px 8px; font-size:.82rem; outline:none; width:100%;"
|
||||||
onkeydown="if(event.key==='Enter')aiGenerate()" />
|
onkeydown="if(event.key==='Enter')aiGenerate()" />
|
||||||
<button class="btn" onclick="aiGenerate()" style="white-space:nowrap;">▶ Генерировать</button>
|
<button class="btn" onclick="aiGenerate()" style="white-space:nowrap;">▶ Генерировать</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="c-ai-result" style="display:none;margin-top:8px;padding:10px 12px;border-radius:6px;font-size:13px;line-height:1.5;white-space:pre-wrap;font-family:monospace;"></div>
|
<div id="c-ai-result"
|
||||||
|
style="display:none;margin-top:8px;padding:10px 12px;border-radius:6px;font-size:13px;line-height:1.5;white-space:pre-wrap;font-family:monospace;">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn ghost" onclick="closeCreate()">Отмена</button>
|
<button class="btn ghost" onclick="closeCreate()">Отмена</button>
|
||||||
@@ -427,7 +493,11 @@
|
|||||||
<div id="edit-modal" class="modal">
|
<div id="edit-modal" class="modal">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h3 id="e-title">Редактирование кода</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;">⚠️ Эта функция управляется Terraform. Изменения могут быть перезаписаны при следующем <code>terraform apply</code>.</div>
|
<div id="e-tf-warn"
|
||||||
|
style="display:none;background:#553300;color:#ffa;padding:8px 12px;border-radius:6px;margin-bottom:10px;font-size:13px;">
|
||||||
|
⚠️ Эта функция управляется Terraform. Изменения могут быть перезаписаны при следующем
|
||||||
|
<code>terraform apply</code>.
|
||||||
|
</div>
|
||||||
<input type="hidden" id="e-lang-hidden" value="">
|
<input type="hidden" id="e-lang-hidden" value="">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -447,10 +517,14 @@
|
|||||||
<label>Код</label>
|
<label>Код</label>
|
||||||
<textarea id="e-code"></textarea>
|
<textarea id="e-code"></textarea>
|
||||||
<div style="margin-top:6px; display:flex; gap:6px; flex-wrap:wrap;">
|
<div style="margin-top:6px; display:flex; gap:6px; flex-wrap:wrap;">
|
||||||
<button class="btn ghost" id="e-ai-btn" onclick="aiCheck('e-code','e-lang-hidden','e-ai-result')">🔍 Проверить синтаксис</button>
|
<button class="btn ghost" id="e-ai-btn" onclick="aiCheck('e-code','e-lang-hidden','e-ai-result')">🔍
|
||||||
<button class="btn ghost" id="e-exp-btn" onclick="aiExplain('e-code','e-lang-hidden','e-ai-result')">📖 Что делает?</button>
|
Проверить синтаксис</button>
|
||||||
|
<button class="btn ghost" id="e-exp-btn" onclick="aiExplain('e-code','e-lang-hidden','e-ai-result')">📖
|
||||||
|
Что делает?</button>
|
||||||
|
</div>
|
||||||
|
<div id="e-ai-result"
|
||||||
|
style="display:none;margin-top:8px;padding:10px 12px;border-radius:6px;font-size:13px;line-height:1.5;white-space:pre-wrap;font-family:monospace;">
|
||||||
</div>
|
</div>
|
||||||
<div id="e-ai-result" style="display:none;margin-top:8px;padding:10px 12px;border-radius:6px;font-size:13px;line-height:1.5;white-space:pre-wrap;font-family:monospace;"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn ghost" onclick="closeEdit()">Отмена</button>
|
<button class="btn ghost" onclick="closeEdit()">Отмена</button>
|
||||||
@@ -471,6 +545,9 @@
|
|||||||
<button id="i-submit" class="btn" onclick="submitInvoke()">Вызвать</button>
|
<button id="i-submit" class="btn" onclick="submitInvoke()">Вызвать</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="i-status" style="margin-top:8px;font-size:13px;color:var(--text-secondary);min-height:20px;"></div>
|
<div id="i-status" style="margin-top:8px;font-size:13px;color:var(--text-secondary);min-height:20px;"></div>
|
||||||
|
<div id="i-meta"
|
||||||
|
style="display:none;margin-top:8px;background:#101c2b;border:1px solid var(--border);border-radius:6px;padding:10px 12px;font-size:13px;color:#d8e5f2;white-space:pre-wrap;">
|
||||||
|
</div>
|
||||||
<div style="margin-top:6px;">
|
<div style="margin-top:6px;">
|
||||||
<label>Response</label>
|
<label>Response</label>
|
||||||
<textarea id="i-resp" readonly style="min-height:160px;"></textarea>
|
<textarea id="i-resp" readonly style="min-height:160px;"></textarea>
|
||||||
@@ -497,7 +574,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getJSON(url) {
|
async function getJSON(url) {
|
||||||
const r = await fetch(url, {headers: authHeaders()});
|
const r = await fetch(url, { headers: authHeaders() });
|
||||||
if (r.status === 401) { doLogout(); throw new Error('Сессия истекла'); }
|
if (r.status === 401) { doLogout(); throw new Error('Сессия истекла'); }
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
let msg = '';
|
let msg = '';
|
||||||
@@ -515,12 +592,12 @@
|
|||||||
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: Object.assign({'Content-Type': 'application/json'}, authHeaders()),
|
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('Сессия истекла'); }
|
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) {
|
||||||
throw new Error(data.error || (url + ': ' + r.status));
|
throw new Error(data.error || (url + ': ' + r.status));
|
||||||
}
|
}
|
||||||
@@ -571,13 +648,9 @@
|
|||||||
code: 'def main():\n return "hello from fission"'
|
code: 'def main():\n return "hello from fission"'
|
||||||
},
|
},
|
||||||
nodejs: {
|
nodejs: {
|
||||||
entrypoint: 'handler',
|
entrypoint: 'main',
|
||||||
code: 'module.exports = async function(context) {\n return {\n status: 200,\n body: "hello from fission"\n };\n}'
|
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: {
|
php: {
|
||||||
entrypoint: 'main.php::handler',
|
entrypoint: 'main.php::handler',
|
||||||
code: '<?php\nfunction handler($context)\n{\n $response = $context["response"];\n $response->getBody()->write("hello from fission");\n}'
|
code: '<?php\nfunction handler($context)\n{\n $response = $context["response"];\n $response->getBody()->write("hello from fission");\n}'
|
||||||
@@ -585,10 +658,6 @@
|
|||||||
ruby: {
|
ruby: {
|
||||||
entrypoint: 'handler',
|
entrypoint: 'handler',
|
||||||
code: '# frozen_string_literal: true\n\ndef handler\n "hello from fission"\nend'
|
code: '# frozen_string_literal: true\n\ndef handler\n "hello from fission"\nend'
|
||||||
},
|
|
||||||
perl: {
|
|
||||||
entrypoint: 'handler',
|
|
||||||
code: 'sub {\n return "hello from fission";\n}'
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -615,6 +684,7 @@
|
|||||||
async function submitCreate() {
|
async function submitCreate() {
|
||||||
const btn = document.getElementById('c-submit');
|
const btn = document.getElementById('c-submit');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
const progress = startTimedStatus('Создаём функцию...', 'Создание функции...', explainDelay);
|
||||||
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');
|
||||||
@@ -631,10 +701,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
closeCreate();
|
closeCreate();
|
||||||
showStatus('\u0424\u0443\u043d\u043a\u0446\u0438\u044f \u0441\u043e\u0437\u0434\u0430\u043d\u0430: ' + name, 'ok');
|
progress.stop('\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('\u041e\u0448\u0438\u0431\u043a\u0430 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u044f: ' + e.message, 'err');
|
progress.stop('\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;
|
||||||
}
|
}
|
||||||
@@ -656,7 +726,6 @@
|
|||||||
else if (envName.includes('go')) lang = 'go';
|
else if (envName.includes('go')) lang = 'go';
|
||||||
else if (envName.includes('ruby')) lang = 'ruby';
|
else if (envName.includes('ruby')) lang = 'ruby';
|
||||||
else if (envName.includes('php')) lang = 'php';
|
else if (envName.includes('php')) lang = 'php';
|
||||||
else if (envName.includes('perl')) lang = 'perl';
|
|
||||||
document.getElementById('e-lang-hidden').value = lang;
|
document.getElementById('e-lang-hidden').value = lang;
|
||||||
// сбросить предыдущий AI-результат
|
// сбросить предыдущий AI-результат
|
||||||
var aiRes = document.getElementById('e-ai-result');
|
var aiRes = document.getElementById('e-ai-result');
|
||||||
@@ -681,15 +750,16 @@
|
|||||||
if (!S.currentEdit) return;
|
if (!S.currentEdit) return;
|
||||||
const btn = document.getElementById('e-submit');
|
const btn = document.getElementById('e-submit');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
const progress = startTimedStatus('Сохраняем код...', 'Сохранение кода...', explainDelay);
|
||||||
try {
|
try {
|
||||||
const name = S.currentEdit.name;
|
const name = S.currentEdit.name;
|
||||||
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/code', 'PUT', {
|
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/code', 'PUT', {
|
||||||
code: document.getElementById('e-code').value
|
code: document.getElementById('e-code').value
|
||||||
});
|
});
|
||||||
closeEdit();
|
closeEdit();
|
||||||
showStatus('\u041a\u043e\u0434 \u043e\u0431\u043d\u043e\u0432\u043b\u0451\u043d: ' + name, 'ok');
|
progress.stop('\u041a\u043e\u0434 \u043e\u0431\u043d\u043e\u0432\u043b\u0451\u043d: ' + name, 'ok');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showStatus('\u041e\u0448\u0438\u0431\u043a\u0430 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f: ' + e.message, 'err');
|
progress.stop('\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;
|
||||||
}
|
}
|
||||||
@@ -699,6 +769,9 @@
|
|||||||
S.currentInvoke = name;
|
S.currentInvoke = name;
|
||||||
document.getElementById('i-title').textContent = '\u0412\u044b\u0437\u043e\u0432: ' + name;
|
document.getElementById('i-title').textContent = '\u0412\u044b\u0437\u043e\u0432: ' + name;
|
||||||
document.getElementById('i-resp').value = '';
|
document.getElementById('i-resp').value = '';
|
||||||
|
document.getElementById('i-status').textContent = '';
|
||||||
|
document.getElementById('i-meta').style.display = 'none';
|
||||||
|
document.getElementById('i-meta').textContent = '';
|
||||||
document.getElementById('invoke-modal').classList.add('open');
|
document.getElementById('invoke-modal').classList.add('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,9 +784,12 @@
|
|||||||
if (!S.currentInvoke) return;
|
if (!S.currentInvoke) return;
|
||||||
const btn = document.getElementById('i-submit');
|
const btn = document.getElementById('i-submit');
|
||||||
const statusEl = document.getElementById('i-status');
|
const statusEl = document.getElementById('i-status');
|
||||||
|
const metaEl = document.getElementById('i-meta');
|
||||||
const respEl = document.getElementById('i-resp');
|
const respEl = document.getElementById('i-resp');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
respEl.value = '';
|
respEl.value = '';
|
||||||
|
metaEl.style.display = 'none';
|
||||||
|
metaEl.textContent = '';
|
||||||
let elapsed = 0;
|
let elapsed = 0;
|
||||||
statusEl.textContent = 'Вызов...';
|
statusEl.textContent = 'Вызов...';
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
@@ -721,9 +797,9 @@
|
|||||||
if (elapsed < 5) {
|
if (elapsed < 5) {
|
||||||
statusEl.textContent = 'Вызов... ' + elapsed + 'с';
|
statusEl.textContent = 'Вызов... ' + elapsed + 'с';
|
||||||
} else if (elapsed < 10) {
|
} else if (elapsed < 10) {
|
||||||
statusEl.textContent = '⏳ Холодный старт — прогрев пула... ' + elapsed + 'с';
|
statusEl.textContent = '⏳ Возможен cold start — прогрев пула... ' + elapsed + 'с';
|
||||||
} else {
|
} else {
|
||||||
statusEl.textContent = '⏳ Холодный старт — ещё немного... ' + elapsed + 'с';
|
statusEl.textContent = '⏳ Возможны cold start или specialization... ' + elapsed + 'с';
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
try {
|
try {
|
||||||
@@ -731,10 +807,24 @@
|
|||||||
let parsed = {};
|
let parsed = {};
|
||||||
if (raw) parsed = JSON.parse(raw);
|
if (raw) parsed = JSON.parse(raw);
|
||||||
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);
|
||||||
statusEl.textContent = '✓ Выполнено за ' + elapsed + 'с';
|
var latencyMs = Number(result.latency_ms || 0);
|
||||||
|
var measuredSeconds = latencyMs > 0 ? Math.max(1, Math.round(latencyMs / 1000)) : elapsed;
|
||||||
|
statusEl.textContent = '✓ Выполнено за ' + formatSeconds(measuredSeconds);
|
||||||
|
metaEl.style.display = 'block';
|
||||||
|
metaEl.textContent = [
|
||||||
|
'HTTP status: ' + (result.status || 'n/a'),
|
||||||
|
'Latency: ' + (latencyMs || 'n/a') + ' ms',
|
||||||
|
'Причина задержки: ' + explainDelay(measuredSeconds),
|
||||||
|
'Invoke URL: ' + (result.invoke_url || 'n/a')
|
||||||
|
].join('\n');
|
||||||
respEl.value = JSON.stringify(result, null, 2);
|
respEl.value = JSON.stringify(result, null, 2);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
statusEl.textContent = '✗ Ошибка после ' + elapsed + 'с';
|
statusEl.textContent = '✗ Ошибка после ' + formatSeconds(elapsed);
|
||||||
|
metaEl.style.display = 'block';
|
||||||
|
metaEl.textContent = [
|
||||||
|
'Последняя ошибка: ' + e.message,
|
||||||
|
'Вероятная причина задержки: ' + explainDelay(elapsed)
|
||||||
|
].join('\n');
|
||||||
respEl.value = 'Ошибка вызова: ' + e.message;
|
respEl.value = 'Ошибка вызова: ' + e.message;
|
||||||
} finally {
|
} finally {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
@@ -745,12 +835,13 @@
|
|||||||
async function removeFn(name) {
|
async function removeFn(name) {
|
||||||
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!' : '';
|
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;
|
if (!confirm('\u0423\u0434\u0430\u043b\u0438\u0442\u044c \u0444\u0443\u043d\u043a\u0446\u0438\u044e ' + name + '?' + tfWarn)) return;
|
||||||
|
var progress = startTimedStatus('\u0423\u0434\u0430\u043b\u044f\u0435\u043c \u0444\u0443\u043d\u043a\u0446\u0438\u044e...', '\u0423\u0434\u0430\u043b\u0435\u043d\u0438\u0435 \u0444\u0443\u043d\u043a\u0446\u0438\u0438...', explainDelay);
|
||||||
try {
|
try {
|
||||||
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name), 'DELETE');
|
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name), 'DELETE');
|
||||||
showStatus('\u0424\u0443\u043d\u043a\u0446\u0438\u044f \u0443\u0434\u0430\u043b\u0435\u043d\u0430: ' + name, 'ok');
|
progress.stop('\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('\u041e\u0448\u0438\u0431\u043a\u0430 \u0443\u0434\u0430\u043b\u0435\u043d\u0438\u044f: ' + e.message, 'err');
|
progress.stop('\u041e\u0448\u0438\u0431\u043a\u0430 \u0443\u0434\u0430\u043b\u0435\u043d\u0438\u044f: ' + e.message, 'err');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,7 +888,7 @@
|
|||||||
'<td class="mono">' + h(route) + '</td>' +
|
'<td class="mono">' + h(route) + '</td>' +
|
||||||
'<td>' + chips + '</td>' +
|
'<td>' + chips + '</td>' +
|
||||||
'<td class="nowrap">' + actions + '</td>' +
|
'<td class="nowrap">' + actions + '</td>' +
|
||||||
'</tr>';
|
'</tr>';
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
document.getElementById('fn-rows').innerHTML = rows || '<tr><td colspan="3">\u041d\u0435\u0442 \u0444\u0443\u043d\u043a\u0446\u0438\u0439</td></tr>';
|
document.getElementById('fn-rows').innerHTML = rows || '<tr><td colspan="3">\u041d\u0435\u0442 \u0444\u0443\u043d\u043a\u0446\u0438\u0439</td></tr>';
|
||||||
@@ -829,11 +920,11 @@
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(API_BASE + '/auth', {
|
const res = await fetch(API_BASE + '/auth', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({token: token, env: env})
|
body: JSON.stringify({ token: token, env: env })
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const d = await res.json().catch(function() { return {}; });
|
const d = await res.json().catch(function () { return {}; });
|
||||||
throw new Error(d.error || 'Ошибка входа');
|
throw new Error(d.error || 'Ошибка входа');
|
||||||
}
|
}
|
||||||
localStorage.setItem('auth_token', token);
|
localStorage.setItem('auth_token', token);
|
||||||
@@ -841,15 +932,15 @@
|
|||||||
hideLoginOverlay();
|
hideLoginOverlay();
|
||||||
// Проверяем статус NS — если не ready, показываем init overlay
|
// Проверяем статус NS — если не ready, показываем init overlay
|
||||||
try {
|
try {
|
||||||
var sr = await fetch(API_BASE + '/ns/status', {headers: {'X-Auth-Token': token, 'X-Auth-Env': env}});
|
var sr = await fetch(API_BASE + '/ns/status', { headers: { 'X-Auth-Token': token, 'X-Auth-Env': env } });
|
||||||
var sd = await sr.json();
|
var sd = await sr.json();
|
||||||
if (!sd.ready) {
|
if (!sd.ready) {
|
||||||
pollNSStatus();
|
pollNSStatus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch(_) {}
|
} catch (_) { }
|
||||||
reloadAll();
|
reloadAll();
|
||||||
} catch(e) {
|
} catch (e) {
|
||||||
errEl.textContent = e.message;
|
errEl.textContent = e.message;
|
||||||
errEl.style.display = 'block';
|
errEl.style.display = 'block';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -860,7 +951,7 @@
|
|||||||
function doLogout() {
|
function doLogout() {
|
||||||
localStorage.removeItem('auth_token');
|
localStorage.removeItem('auth_token');
|
||||||
localStorage.removeItem('auth_env');
|
localStorage.removeItem('auth_env');
|
||||||
try { document.getElementById('l-token').value = ''; } catch(_) {}
|
try { document.getElementById('l-token').value = ''; } catch (_) { }
|
||||||
showLoginOverlay();
|
showLoginOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -878,11 +969,11 @@
|
|||||||
resEl.style.color = 'var(--fg)';
|
resEl.style.color = 'var(--fg)';
|
||||||
resEl.textContent = 'Отправляю код в LLM...';
|
resEl.textContent = 'Отправляю код в LLM...';
|
||||||
try {
|
try {
|
||||||
var data = await requestJSON(API_BASE + '/ai/check', 'POST', {language: lang, code: code});
|
var data = await requestJSON(API_BASE + '/ai/check', 'POST', { language: lang, code: code });
|
||||||
resEl.style.background = data.ok ? '#1a3a1a' : '#3a1a1a';
|
resEl.style.background = data.ok ? '#1a3a1a' : '#3a1a1a';
|
||||||
resEl.style.color = data.ok ? '#8f8' : '#f88';
|
resEl.style.color = data.ok ? '#8f8' : '#f88';
|
||||||
resEl.textContent = data.result || '(пустой ответ)';
|
resEl.textContent = data.result || '(пустой ответ)';
|
||||||
} catch(e) {
|
} catch (e) {
|
||||||
resEl.style.background = '#3a2a00';
|
resEl.style.background = '#3a2a00';
|
||||||
resEl.style.color = '#ffa';
|
resEl.style.color = '#ffa';
|
||||||
resEl.textContent = 'Ошибка: ' + e.message;
|
resEl.textContent = 'Ошибка: ' + e.message;
|
||||||
@@ -893,6 +984,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
var _initPolling = false;
|
var _initPolling = false;
|
||||||
|
var _initTickBusy = false;
|
||||||
|
var _initStartedAt = 0;
|
||||||
|
|
||||||
function initLog(msg) {
|
function initLog(msg) {
|
||||||
var el = document.getElementById('init-log');
|
var el = document.getElementById('init-log');
|
||||||
@@ -901,9 +994,109 @@
|
|||||||
el.scrollTop = el.scrollHeight;
|
el.scrollTop = el.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSeconds(totalSeconds) {
|
||||||
|
totalSeconds = Math.max(0, Number(totalSeconds) || 0);
|
||||||
|
var minutes = Math.floor(totalSeconds / 60);
|
||||||
|
var seconds = totalSeconds % 60;
|
||||||
|
if (minutes <= 0) return seconds + 'с';
|
||||||
|
return minutes + 'м ' + seconds + 'с';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setInitElapsed(seconds) {
|
||||||
|
document.getElementById('init-elapsed').textContent = formatSeconds(seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setInitCurrentStage(text) {
|
||||||
|
document.getElementById('init-current-stage').textContent = text || 'Ожидание старта';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBox(id, text) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
if (!text) {
|
||||||
|
el.style.display = 'none';
|
||||||
|
el.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.style.display = 'block';
|
||||||
|
el.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function missingKeys(obj) {
|
||||||
|
return Object.keys(obj || {}).filter(function (key) {
|
||||||
|
return !obj[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstPendingStage(stages) {
|
||||||
|
var list = stages || [];
|
||||||
|
for (var i = 0; i < list.length; i++) {
|
||||||
|
if (!list[i].done) return list[i].name;
|
||||||
|
}
|
||||||
|
return list.length ? list[list.length - 1].name : 'Ожидание старта';
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeInitReason(debug) {
|
||||||
|
if (!debug) return 'Собираем диагностические данные по namespace и control-plane...';
|
||||||
|
if (!debug.exists) return 'Ещё не создан Kubernetes namespace пользователя.';
|
||||||
|
if (!debug.active) return 'Namespace создан, но Kubernetes ещё не перевёл его в состояние Active.';
|
||||||
|
|
||||||
|
var preparation = debug.preparation || {};
|
||||||
|
if (!preparation.managedByConsole) return 'Namespace существует, но ещё не помечен как managed-by=fission-console.';
|
||||||
|
if (!preparation.watcherLabel) return 'Namespace ещё не помечен label fission.io/managed=true для NSWatcher.';
|
||||||
|
|
||||||
|
var missingSAs = missingKeys(preparation.serviceAccounts);
|
||||||
|
if (missingSAs.length) return 'Не готовы bootstrap service accounts: ' + missingSAs.join(', ') + '.';
|
||||||
|
|
||||||
|
var missingRBs = missingKeys(preparation.roleBindings);
|
||||||
|
if (missingRBs.length) return 'Не готовы bootstrap role bindings: ' + missingRBs.join(', ') + '.';
|
||||||
|
|
||||||
|
var control = debug.controlPlane || {};
|
||||||
|
if (!control.executor || !control.executor.ready) {
|
||||||
|
return 'Executor ещё не ready. Возможна задержка из-за cold start, image pull или specialization.';
|
||||||
|
}
|
||||||
|
if (!control.router || !control.router.ready) {
|
||||||
|
return 'Router ещё не ready. HTTP маршрутизация пока не активна.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Ожидаем финальное подтверждение готовности control-plane.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeInit(status, debug) {
|
||||||
|
var done = status && status.stages ? status.stages.filter(function (s) { return s.done; }).length : 0;
|
||||||
|
var namespace = debug && debug.namespace ? debug.namespace : 'не определён';
|
||||||
|
return 'Namespace: ' + namespace + '. Завершено шагов: ' + done + '/3.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function explainDelay(seconds) {
|
||||||
|
if (seconds < 5) return 'Обычная операция без заметной задержки.';
|
||||||
|
if (seconds < 10) return 'Вероятен cold start: прогрев пула, image pull или ожидание specialization.';
|
||||||
|
return 'Задержка выше нормы: возможны cold start, specialization или ожидание готовности control-plane.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTimedStatus(startText, waitingPrefix, waitingHint) {
|
||||||
|
var startedAt = Date.now();
|
||||||
|
showStatus(startText, '');
|
||||||
|
var timer = setInterval(function () {
|
||||||
|
var elapsed = Math.max(1, Math.floor((Date.now() - startedAt) / 1000));
|
||||||
|
var text = waitingPrefix + ' ' + formatSeconds(elapsed);
|
||||||
|
if (waitingHint) text += ' · ' + waitingHint(elapsed);
|
||||||
|
showStatus(text, '');
|
||||||
|
}, 1000);
|
||||||
|
return {
|
||||||
|
stop: function (message, kind) {
|
||||||
|
clearInterval(timer);
|
||||||
|
var elapsed = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
|
||||||
|
var text = message;
|
||||||
|
if (message) text += ' за ' + formatSeconds(elapsed);
|
||||||
|
showStatus(text, kind || '');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function renderInitStages(stages) {
|
function renderInitStages(stages) {
|
||||||
var el = document.getElementById('init-stages');
|
var el = document.getElementById('init-stages');
|
||||||
el.innerHTML = stages.map(function(s) {
|
el.innerHTML = stages.map(function (s) {
|
||||||
var icon = s.done ? '✅' : '⏳';
|
var icon = s.done ? '✅' : '⏳';
|
||||||
var color = s.done ? '#4caf50' : '#8bc7ff';
|
var color = s.done ? '#4caf50' : '#8bc7ff';
|
||||||
return '<div style="font-size:13px; color:' + color + ';">' + icon + ' ' + s.name + '</div>';
|
return '<div style="font-size:13px; color:' + color + ';">' + icon + ' ' + s.name + '</div>';
|
||||||
@@ -913,33 +1106,57 @@
|
|||||||
async function pollNSStatus() {
|
async function pollNSStatus() {
|
||||||
if (_initPolling) return;
|
if (_initPolling) return;
|
||||||
_initPolling = true;
|
_initPolling = true;
|
||||||
|
_initTickBusy = false;
|
||||||
|
_initStartedAt = Date.now();
|
||||||
var overlay = document.getElementById('init-overlay');
|
var overlay = document.getElementById('init-overlay');
|
||||||
overlay.style.display = 'flex';
|
overlay.style.display = 'flex';
|
||||||
|
document.getElementById('init-log').textContent = '';
|
||||||
|
setInitElapsed(0);
|
||||||
|
setInitCurrentStage('Подготовка запуска');
|
||||||
|
setBox('init-summary', 'Проверяем состояние namespace и control-plane...');
|
||||||
|
setBox('init-reason', 'Если пользователь новый, возможны cold start и создание bootstrap ресурсов.');
|
||||||
|
setBox('init-error', '');
|
||||||
initLog('Запуск инициализации...');
|
initLog('Запуск инициализации...');
|
||||||
var attempt = 0;
|
var attempt = 0;
|
||||||
var maxAttempts = 60; // 5 минут
|
var maxAttempts = 60; // 5 минут
|
||||||
var interval = setInterval(async function() {
|
var interval = setInterval(async function () {
|
||||||
|
if (_initTickBusy) return;
|
||||||
|
_initTickBusy = true;
|
||||||
attempt++;
|
attempt++;
|
||||||
try {
|
try {
|
||||||
var r = await fetch(API_BASE + '/ns/status', {headers: authHeaders()});
|
var elapsed = Math.floor((Date.now() - _initStartedAt) / 1000);
|
||||||
var d = await r.json();
|
setInitElapsed(elapsed);
|
||||||
|
var statusResp = await fetch(API_BASE + '/ns/status', { headers: authHeaders() });
|
||||||
|
var debugResp = await fetch(API_BASE + '/ns/debug', { headers: authHeaders() });
|
||||||
|
var d = await statusResp.json();
|
||||||
|
var dbg = await debugResp.json().catch(function () { return null; });
|
||||||
if (d.stages) renderInitStages(d.stages);
|
if (d.stages) renderInitStages(d.stages);
|
||||||
var done = d.stages ? d.stages.filter(function(s){return s.done;}).length : 0;
|
setInitCurrentStage(firstPendingStage(d.stages));
|
||||||
|
setBox('init-summary', summarizeInit(d, dbg));
|
||||||
|
setBox('init-reason', describeInitReason(dbg));
|
||||||
|
setBox('init-error', '');
|
||||||
|
var done = d.stages ? d.stages.filter(function (s) { return s.done; }).length : 0;
|
||||||
initLog('Шагов завершено: ' + done + '/3');
|
initLog('Шагов завершено: ' + done + '/3');
|
||||||
if (d.ready) {
|
if (d.ready) {
|
||||||
initLog('✅ Готово! Загружаем консоль...');
|
initLog('✅ Готово! Загружаем консоль...');
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
_initPolling = false;
|
_initPolling = false;
|
||||||
|
_initTickBusy = false;
|
||||||
overlay.style.display = 'none';
|
overlay.style.display = 'none';
|
||||||
reloadAll();
|
reloadAll();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch (e) {
|
||||||
|
setBox('init-error', 'Последняя ошибка: ' + e.message);
|
||||||
initLog('Ошибка опроса: ' + e.message);
|
initLog('Ошибка опроса: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
_initTickBusy = false;
|
||||||
}
|
}
|
||||||
if (attempt >= maxAttempts) {
|
if (attempt >= maxAttempts) {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
_initPolling = false;
|
_initPolling = false;
|
||||||
|
_initTickBusy = false;
|
||||||
|
setBox('init-error', 'Превышено время ожидания. Чаще всего это bootstrap RBAC, control-plane readiness или холодный старт.');
|
||||||
initLog('⚠️ Превышено время ожидания. Попробуйте обновить страницу.');
|
initLog('⚠️ Превышено время ожидания. Попробуйте обновить страницу.');
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
@@ -982,8 +1199,7 @@
|
|||||||
white-space:pre-wrap; word-break:break-word;
|
white-space:pre-wrap; word-break:break-word;
|
||||||
">Чем могу помочь?</div>
|
">Чем могу помочь?</div>
|
||||||
<div style="display:flex; gap:6px;">
|
<div style="display:flex; gap:6px;">
|
||||||
<input id="assistant-input" type="text" placeholder="Введи вопрос..."
|
<input id="assistant-input" type="text" placeholder="Введи вопрос..." style="flex:1; background:#2a2a42; border:1px solid #3a3a5c; border-radius:4px;
|
||||||
style="flex:1; background:#2a2a42; border:1px solid #3a3a5c; border-radius:4px;
|
|
||||||
color:#cdd6f4; padding:6px 8px; font-size:.82rem; outline:none;"
|
color:#cdd6f4; padding:6px 8px; font-size:.82rem; outline:none;"
|
||||||
onkeydown="if(event.key==='Enter')askAssistant()" />
|
onkeydown="if(event.key==='Enter')askAssistant()" />
|
||||||
<button onclick="askAssistant()" style="
|
<button onclick="askAssistant()" style="
|
||||||
@@ -1017,7 +1233,7 @@
|
|||||||
});
|
});
|
||||||
var d = await r.json();
|
var d = await r.json();
|
||||||
msgs.textContent = msgs.textContent.replace('⏳ ...', '🤖 ' + (d.answer || d.error || 'Нет ответа'));
|
msgs.textContent = msgs.textContent.replace('⏳ ...', '🤖 ' + (d.answer || d.error || 'Нет ответа'));
|
||||||
} catch(e) {
|
} catch (e) {
|
||||||
msgs.textContent = msgs.textContent.replace('⏳ ...', '❌ Ошибка: ' + e.message);
|
msgs.textContent = msgs.textContent.replace('⏳ ...', '❌ Ошибка: ' + e.message);
|
||||||
}
|
}
|
||||||
msgs.scrollTop = msgs.scrollHeight;
|
msgs.scrollTop = msgs.scrollHeight;
|
||||||
@@ -1039,13 +1255,13 @@
|
|||||||
resEl.textContent = 'Запрашиваю у LLM...';
|
resEl.textContent = 'Запрашиваю у LLM...';
|
||||||
try {
|
try {
|
||||||
var q = 'Напиши функцию Fission на ' + lang + ' с именем "' + name + '". Функция должна: ' + desc + '. Верни только чистый код без пояснений и без markdown-блоков.';
|
var q = 'Напиши функцию Fission на ' + lang + ' с именем "' + name + '". Функция должна: ' + desc + '. Верни только чистый код без пояснений и без markdown-блоков.';
|
||||||
var d = await requestJSON('/console/api/ai/ask', 'POST', {question: q});
|
var d = await requestJSON('/console/api/ai/ask', 'POST', { question: q });
|
||||||
var code = (d.answer || '').replace(/^```[\w]*\n?/, '').replace(/\n?```$/, '');
|
var code = (d.answer || '').replace(/^```[\w]*\n?/, '').replace(/\n?```$/, '');
|
||||||
document.getElementById('c-code').value = code;
|
document.getElementById('c-code').value = code;
|
||||||
document.getElementById('c-gen-prompt').style.display = 'none';
|
document.getElementById('c-gen-prompt').style.display = 'none';
|
||||||
resEl.style.background = '#1a3a1a'; resEl.style.color = '#8f8';
|
resEl.style.background = '#1a3a1a'; resEl.style.color = '#8f8';
|
||||||
resEl.textContent = 'Код сгенерирован и вставлен.';
|
resEl.textContent = 'Код сгенерирован и вставлен.';
|
||||||
} catch(e) {
|
} catch (e) {
|
||||||
resEl.style.background = '#3a2a00'; resEl.style.color = '#ffa';
|
resEl.style.background = '#3a2a00'; resEl.style.color = '#ffa';
|
||||||
resEl.textContent = 'Ошибка: ' + e.message;
|
resEl.textContent = 'Ошибка: ' + e.message;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1058,16 +1274,16 @@
|
|||||||
var resEl = document.getElementById(resultId);
|
var resEl = document.getElementById(resultId);
|
||||||
var btnId = codeId === 'c-code' ? 'c-exp-btn' : 'e-exp-btn';
|
var btnId = codeId === 'c-code' ? 'c-exp-btn' : 'e-exp-btn';
|
||||||
var btn = document.getElementById(btnId);
|
var btn = document.getElementById(btnId);
|
||||||
if (!code) { resEl.style.display='block'; resEl.style.background='var(--bg-alt)'; resEl.textContent='Введите код.'; return; }
|
if (!code) { resEl.style.display = 'block'; resEl.style.background = 'var(--bg-alt)'; resEl.textContent = 'Введите код.'; return; }
|
||||||
btn.disabled = true; btn.textContent = '⏳ Объясняю...';
|
btn.disabled = true; btn.textContent = '⏳ Объясняю...';
|
||||||
resEl.style.display = 'block'; resEl.style.background = 'var(--bg-alt)'; resEl.style.color = 'var(--fg)';
|
resEl.style.display = 'block'; resEl.style.background = 'var(--bg-alt)'; resEl.style.color = 'var(--fg)';
|
||||||
resEl.textContent = 'Запрашиваю у LLM...';
|
resEl.textContent = 'Запрашиваю у LLM...';
|
||||||
try {
|
try {
|
||||||
var q = 'Кратко объясни что делает этот ' + lang + ' код:\n' + code;
|
var q = 'Кратко объясни что делает этот ' + lang + ' код:\n' + code;
|
||||||
var d = await requestJSON('/console/api/ai/ask', 'POST', {question: q});
|
var d = await requestJSON('/console/api/ai/ask', 'POST', { question: q });
|
||||||
resEl.style.background = '#1a2a3a'; resEl.style.color = '#8cf';
|
resEl.style.background = '#1a2a3a'; resEl.style.color = '#8cf';
|
||||||
resEl.textContent = d.answer || '(пустой ответ)';
|
resEl.textContent = d.answer || '(пустой ответ)';
|
||||||
} catch(e) {
|
} catch (e) {
|
||||||
resEl.style.background = '#3a2a00'; resEl.style.color = '#ffa';
|
resEl.style.background = '#3a2a00'; resEl.style.color = '#ffa';
|
||||||
resEl.textContent = 'Ошибка: ' + e.message;
|
resEl.textContent = 'Ошибка: ' + e.message;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1079,4 +1295,5 @@
|
|||||||
<!-- end ai/ask feature -->
|
<!-- end ai/ask feature -->
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
# Отчёт 2026-04-26 — Layer2 console, live validation и причина NodeJS сбоя
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Зафиксировать текущее состояние Layer2 API и console UI в репозитории fission, задокументировать выполненные live-проверки и отдельно разобрать корневую причину, по которой NodeJS-функции раньше создавались, но не вызывались.
|
||||||
|
|
||||||
|
## Что было сделано в этой сессии
|
||||||
|
|
||||||
|
### 1. Namespace status/debug приведён к реальному layer1 поведению
|
||||||
|
|
||||||
|
Старый код статуса namespace не соответствовал watcher-based схеме из layer1. В console backend логика была перепривязана к реальной модели readiness и bootstrap.
|
||||||
|
|
||||||
|
Сделано:
|
||||||
|
- добавлена актуальная проверка состояния namespace и control-plane;
|
||||||
|
- добавлен debug endpoint для UI;
|
||||||
|
- wiring зарегистрирован в HTTP server.
|
||||||
|
|
||||||
|
Основные точки:
|
||||||
|
- [console/internal/api/ns_status.go](console/internal/api/ns_status.go)
|
||||||
|
- [console/internal/api/server.go](console/internal/api/server.go)
|
||||||
|
|
||||||
|
### 2. UI стал показывать прогресс и причины задержек
|
||||||
|
|
||||||
|
В UI добавлена более явная обратная связь по долгим операциям:
|
||||||
|
- таймер deploy/invoke;
|
||||||
|
- стадия выполнения;
|
||||||
|
- причина задержки, если функция ещё не готова;
|
||||||
|
- более явное сообщение об ошибке invoke/deploy.
|
||||||
|
|
||||||
|
Это было нужно для демонстрации: пользователь не должен видеть просто зависание без контекста.
|
||||||
|
|
||||||
|
Основная точка:
|
||||||
|
- [console/ui/index.html](console/ui/index.html)
|
||||||
|
|
||||||
|
### 3. Go выведен из текущего demo-сценария
|
||||||
|
|
||||||
|
Go не удалялся из backend-логики целиком, но был исключён из текущего UI create-flow и из оперативного demo-фокуса. Причина практическая: это единственный компилируемый язык в текущем наборе, а на стенде он зависал на build pending и не был нужен для ближайшей демонстрации.
|
||||||
|
|
||||||
|
Основная точка:
|
||||||
|
- [console/ui/index.html](console/ui/index.html)
|
||||||
|
|
||||||
|
### 4. NodeJS починен на уровне packaging + entrypoint compatibility
|
||||||
|
|
||||||
|
Это был главный runtime-дефект в конце сессии.
|
||||||
|
|
||||||
|
Изменения:
|
||||||
|
- backend-упаковщик NodeJS теперь экспортирует сразу `main`, `handler` и `default`;
|
||||||
|
- UI-шаблон NodeJS создаёт новые функции с entrypoint `main`;
|
||||||
|
- добавлен узкий unit-test на содержимое zip wrapper.
|
||||||
|
|
||||||
|
Основные точки:
|
||||||
|
- [console/internal/runtime/nodejs.go](console/internal/runtime/nodejs.go)
|
||||||
|
- [console/internal/runtime/nodejs_test.go](console/internal/runtime/nodejs_test.go)
|
||||||
|
- [console/internal/runtime/entrypoint.go](console/internal/runtime/entrypoint.go)
|
||||||
|
- [console/ui/index.html](console/ui/index.html)
|
||||||
|
|
||||||
|
## Почему NodeJS не работал
|
||||||
|
|
||||||
|
### Симптом
|
||||||
|
|
||||||
|
Свежесозданная NodeJS-функция успешно создавалась, package имел `buildstatus: none`, poolmgr pod поднимался, но invoke стабильно возвращал timeout:
|
||||||
|
|
||||||
|
`invoke "node-..." timeout after 20s: function specialization likely failed`
|
||||||
|
|
||||||
|
### Что показала диагностика
|
||||||
|
|
||||||
|
В кластере у сломанной функции одновременно наблюдалось следующее:
|
||||||
|
|
||||||
|
1. В backend default entrypoint для NodeJS уже был `main`.
|
||||||
|
См. [console/internal/runtime/entrypoint.go](console/internal/runtime/entrypoint.go).
|
||||||
|
|
||||||
|
2. В UI исторический шаблон NodeJS создавал функцию с entrypoint `handler`.
|
||||||
|
|
||||||
|
3. Генерируемый `main.js` wrapper не экспортировал именованные entrypoint-ы `main` и `handler` одновременно. По факту он не обеспечивал совместимость между старым UI-поведением и backend default semantics.
|
||||||
|
|
||||||
|
4. В результате в `Function.spec.package.functionName` оказывался `handler`, а в содержимом package literal не было гарантированной совместимой named export для этого имени.
|
||||||
|
|
||||||
|
5. Fission specialization доходил до стадии загрузки кода, но не мог корректно разрешить entrypoint и заканчивал таймаутом specialization.
|
||||||
|
|
||||||
|
### Корневая причина
|
||||||
|
|
||||||
|
Корневая причина была не в builder, не в Package CRD и не в самом создании environment.
|
||||||
|
|
||||||
|
Проблема была в несовместимости между:
|
||||||
|
- ожидаемым именем entrypoint в Function spec;
|
||||||
|
- историческим значением entrypoint из UI;
|
||||||
|
- фактически экспортируемыми символами в NodeJS wrapper.
|
||||||
|
|
||||||
|
Коротко: backend и UI расходились по имени entrypoint, а zip wrapper не был сделан обратносуместимым.
|
||||||
|
|
||||||
|
### Исправление
|
||||||
|
|
||||||
|
В [console/internal/runtime/nodejs.go](console/internal/runtime/nodejs.go) wrapper был изменён так, чтобы:
|
||||||
|
- разрешать user code как function, default, handler или main;
|
||||||
|
- экспортировать `__invoke` сразу как `main` и как `handler`;
|
||||||
|
- сохранить также `default` export.
|
||||||
|
|
||||||
|
Ключевой фрагмент поведения теперь такой:
|
||||||
|
- поиск callable идёт через `_fn.default || _fn.handler || _fn.main`;
|
||||||
|
- наружу wrapper экспортирует `main`, `handler`, `default`.
|
||||||
|
|
||||||
|
Это снимает несовместимость и для старых client-side ожиданий, и для нового backend default entrypoint.
|
||||||
|
|
||||||
|
### Проверка исправления
|
||||||
|
|
||||||
|
1. Unit validation:
|
||||||
|
- `go test ./internal/runtime ./internal/api ./cmd/server` — PASS.
|
||||||
|
|
||||||
|
2. Live rollout:
|
||||||
|
- выкачен image `naeel/fission-console:v0.8.17`;
|
||||||
|
- deployment `fission-console` успешно обновлён до этого образа.
|
||||||
|
|
||||||
|
3. Live retest после rollout:
|
||||||
|
- создана свежая NodeJS-функция через public console API;
|
||||||
|
- create вернул `201`;
|
||||||
|
- первый же invoke вернул `200` и `node-fix-ok`.
|
||||||
|
|
||||||
|
Вывод: проблема была именно в entrypoint/export compatibility, и на новых функциях после rollout она устранена.
|
||||||
|
|
||||||
|
Важно: уже созданные до фикса NodeJS-функции не чинятся автоматически, потому что их старый package literal уже записан в кластере.
|
||||||
|
|
||||||
|
## Что проверено живыми тестами
|
||||||
|
|
||||||
|
### Языки
|
||||||
|
|
||||||
|
К моменту завершения фикса подтверждено:
|
||||||
|
- Python: deploy/invoke PASS;
|
||||||
|
- PHP: deploy/invoke PASS;
|
||||||
|
- Ruby: deploy/invoke PASS;
|
||||||
|
- NodeJS: после выката `v0.8.17` deploy/invoke PASS на свежей функции;
|
||||||
|
- Go: исключён из текущего demo-path.
|
||||||
|
- Perl: исключён из текущего demo-path после нестабильного specialization в `perl-env`.
|
||||||
|
|
||||||
|
### Многопользовательский сценарий
|
||||||
|
|
||||||
|
Прогон с 10 параллельными пользователями показал:
|
||||||
|
- `CREATE_OK=10/10`;
|
||||||
|
- `INVOKE_OK=10/10`.
|
||||||
|
|
||||||
|
Отдельно ранее наблюдался шум на первичном init (`GET /functions`) с частью ответов `000000`, то есть проблема была не в create/invoke-конвейере, а в кратковременной нестабильности первичного запроса или timeout-режима самого тестового скрипта.
|
||||||
|
|
||||||
|
## Что важно помнить перед следующим циклом тестов
|
||||||
|
|
||||||
|
1. Для длинных прогонов на этом стенде безопаснее использовать self-completing SSH-команды с жёсткими timeout, а не фоновые или интерактивные сценарии.
|
||||||
|
2. Для NodeJS нужно тестировать только новые функции, созданные уже после rollout исправленного console image.
|
||||||
|
3. Для demo сейчас приоритетны Python, NodeJS, PHP, Ruby.
|
||||||
|
4. Go в UI скрыт намеренно и не должен снова появиться в create-flow до отдельного решения по builder path.
|
||||||
|
|
||||||
|
## Автоочистка test namespace
|
||||||
|
|
||||||
|
После серии прогонов на стенде начали накапливаться десятки и сотни user namespace от тестов. Это мешает ручной диагностике и засоряет cluster state.
|
||||||
|
|
||||||
|
Чтобы это больше не копилось:
|
||||||
|
- добавлен helper [cleanup_test_namespaces.sh](cleanup_test_namespaces.sh);
|
||||||
|
- [tests_v2.sh](tests_v2.sh) теперь в `trap EXIT` удаляет все namespace, созданные своими test sub;
|
||||||
|
- [test_multitenant_ns.sh](test_multitenant_ns.sh) тоже удаляет свои namespace по завершении, включая аварийный выход.
|
||||||
|
|
||||||
|
Дополнительно на стенде был запущен разовый cleanup накопившихся test namespace с префиксами `fission-`, `diag-`, `l1-test-`, `rbac-verify-`, `sa-verify-`.
|
||||||
|
|
||||||
|
## Следующий шаг
|
||||||
|
|
||||||
|
Следующий практический шаг после этого отчёта:
|
||||||
|
- удалить все текущие Fission-функции и managed namespaces;
|
||||||
|
- затем прогнать новый набор тестов с нуля;
|
||||||
|
- отдельно включить сценарии, которые имитируют пользовательские ошибки в UI: пустые поля, дубли, неверный entrypoint, плохой код, ранний invoke и повторные клики.
|
||||||
|
|
||||||
|
## Обновление: удаление Perl из demo-path
|
||||||
|
|
||||||
|
После отдельной проверки выяснилось, что Perl ломается не только в multi-tenant console path, но и на обычном single-user Fission через `simple-perl-env` в `default` namespace. Поэтому для demo-path Perl был отключён на стороне console.
|
||||||
|
|
||||||
|
Что изменено:
|
||||||
|
- Perl убран из UI create-flow;
|
||||||
|
- Perl убран из supported language map console-side;
|
||||||
|
- Perl убран из `ai/check` language set;
|
||||||
|
- Perl убран из live regression сценариев demo-path.
|
||||||
|
|
||||||
|
Live rollout:
|
||||||
|
- собран и выкачен image `naeel/fission-console:v0.8.19`;
|
||||||
|
- deployment `fission-console` обновлён до `naeel/fission-console:v0.8.19`;
|
||||||
|
- rollout completed successfully.
|
||||||
|
|
||||||
|
Что проверено после rollout `v0.8.19`:
|
||||||
|
- `go test ./internal/... ./cmd/server/...` — PASS;
|
||||||
|
- `bash ./test_linters.sh` — PASS (`PASS=31 FAIL=0`);
|
||||||
|
- `KUBECTL_DIRECT=1 ./test_ui_user_flows.sh` — PASS (`PASS=26 FAIL=0`), после усиления invoke-диагностики и разведения Python / Node / PHP+Ruby по разным user namespaces;
|
||||||
|
- `KUBECTL_DIRECT=1 ./smoke_live_multiuser.sh 10` — `CREATE_OK=10/10`, `INVOKE_OK=10/10`, но первичный init дал `INIT_OK=6/10` из-за кратковременных `GET /functions -> 000000`;
|
||||||
|
- после добавления retry в init: `KUBECTL_DIRECT=1 ./smoke_live_multiuser.sh 10` — `INIT_OK=10/10`, `CREATE_OK=10/10`, `INVOKE_OK=10/10`;
|
||||||
|
- новый параллельный сценарий `KUBECTL_DIRECT=1 ./smoke_user_journeys.sh 5` — `USER_JOURNEYS_PASS=5/5`;
|
||||||
|
- Python / NodeJS / PHP / Ruby demo-path остаются рабочими;
|
||||||
|
- Perl отсутствует в live UI surface намеренно.
|
||||||
|
|
||||||
|
Отдельно зафиксирована причина ложного падения прежнего `test_ui_user_flows.sh`: это был не баг PHP/Ruby runtime и не ошибка create-path, а quota одного user namespace. Когда в одном и том же namespace последовательно держались Python, NodeJS update и затем PHP/Ruby poolmgr pod'ы, Fission ловил `exceeded quota: user-quota, requested: limits.cpu=1, used: limits.cpu=4, limited: limits.cpu=4`, после чего invoke возвращал `timeout after 20s: function specialization likely failed`. Для live user-flow smoke сценарии были разведены по разным test users/namespaces, сохранив совместную проверку PHP+Ruby в одном namespace.
|
||||||
|
|
||||||
|
Дополнительно исправлены сами тесты:
|
||||||
|
- `test_multitenant_ns.sh` больше не использует устаревший SSH key path;
|
||||||
|
- `test_multitenant_ns.sh` теперь JSON-экранирует многострочный Python code payload при create function.
|
||||||
Executable
+117
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE="https://fission.kube5s.ru/console/api"
|
||||||
|
COUNT=${1:-10}
|
||||||
|
RUN_ID="mt$(date +%s | tail -c 6)"
|
||||||
|
ROOT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
NS_CLEANUP_SCRIPT="${ROOT_DIR}/cleanup_test_namespaces.sh"
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
|
||||||
|
declare -a USERS=()
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$TMP" 2>/dev/null || true
|
||||||
|
if [ -x "$NS_CLEANUP_SCRIPT" ] && [ ${#USERS[@]} -gt 0 ]; then
|
||||||
|
KUBECTL_DIRECT=1 "$NS_CLEANUP_SCRIPT" "${USERS[@]}" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
json_escape() {
|
||||||
|
python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'
|
||||||
|
}
|
||||||
|
|
||||||
|
init_call() {
|
||||||
|
local user="$1"
|
||||||
|
local code=""
|
||||||
|
local try
|
||||||
|
for try in 1 2 3; do
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 30 -X GET "${BASE}/functions" -H "X-Test-Sub: ${user}" 2>/dev/null || echo 000)
|
||||||
|
echo "INIT_TRY user=${user} try=${try} http=${code}" >&2
|
||||||
|
if [ "$code" = "200" ]; then
|
||||||
|
printf '%s' "$code"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
[ "$try" -lt 3 ] && sleep 2
|
||||||
|
done
|
||||||
|
printf '%s' "$code"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== MULTIUSER TEST START run=$RUN_ID count=$COUNT ==="
|
||||||
|
|
||||||
|
for i in $(seq 1 "$COUNT"); do
|
||||||
|
USERS+=("${RUN_ID}-u${i}@test.local")
|
||||||
|
(
|
||||||
|
U="${RUN_ID}-u${i}@test.local"
|
||||||
|
CODE=$(init_call "${U}")
|
||||||
|
echo "$i:$CODE:$U" > "${TMP}/init-$i"
|
||||||
|
) &
|
||||||
|
done
|
||||||
|
wait
|
||||||
|
|
||||||
|
INIT_OK=0
|
||||||
|
for i in $(seq 1 "$COUNT"); do
|
||||||
|
R=$(cat "${TMP}/init-$i" 2>/dev/null || echo "$i:000:")
|
||||||
|
C=$(echo "$R" | cut -d: -f2)
|
||||||
|
if [ "$C" = "200" ]; then
|
||||||
|
INIT_OK=$((INIT_OK+1))
|
||||||
|
else
|
||||||
|
echo "INIT_FAIL user$i http=$C"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "INIT_OK=$INIT_OK/$COUNT"
|
||||||
|
|
||||||
|
for i in $(seq 1 "$COUNT"); do
|
||||||
|
(
|
||||||
|
U="${RUN_ID}-u${i}@test.local"
|
||||||
|
FN="mtfn-${RUN_ID}-${i}"
|
||||||
|
CODE_TEXT=$(printf 'def main():\n return "ok-%s-%s"\n' "$RUN_ID" "$i")
|
||||||
|
CODE_JSON=$(printf '%s' "$CODE_TEXT" | json_escape)
|
||||||
|
BODY=$(printf '{"name":"%s","language":"python","code":%s}' "$FN" "$CODE_JSON")
|
||||||
|
C=$(curl -s -o /dev/null -w "%{http_code}" --max-time 60 -X POST "${BASE}/functions" -H "X-Test-Sub: ${U}" -H "Content-Type: application/json" -d "$BODY" 2>/dev/null || echo 000)
|
||||||
|
echo "$i:$C:$FN:$U" > "${TMP}/create-$i"
|
||||||
|
) &
|
||||||
|
done
|
||||||
|
wait
|
||||||
|
|
||||||
|
CREATE_OK=0
|
||||||
|
for i in $(seq 1 "$COUNT"); do
|
||||||
|
R=$(cat "${TMP}/create-$i" 2>/dev/null || echo "$i:000::")
|
||||||
|
C=$(echo "$R" | cut -d: -f2)
|
||||||
|
if [ "$C" = "200" ] || [ "$C" = "201" ]; then
|
||||||
|
CREATE_OK=$((CREATE_OK+1))
|
||||||
|
else
|
||||||
|
echo "CREATE_FAIL user$i http=$C"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "CREATE_OK=$CREATE_OK/$COUNT"
|
||||||
|
|
||||||
|
sleep 8
|
||||||
|
|
||||||
|
for i in $(seq 1 "$COUNT"); do
|
||||||
|
(
|
||||||
|
U="${RUN_ID}-u${i}@test.local"
|
||||||
|
FN="mtfn-${RUN_ID}-${i}"
|
||||||
|
C=$(curl -s -o /dev/null -w "%{http_code}" --max-time 40 -X POST "${BASE}/functions/${FN}/invoke" -H "X-Test-Sub: ${U}" -H "Content-Type: application/json" -d '{}' 2>/dev/null || echo 000)
|
||||||
|
echo "$i:$C" > "${TMP}/invoke-$i"
|
||||||
|
) &
|
||||||
|
done
|
||||||
|
wait
|
||||||
|
|
||||||
|
INVOKE_OK=0
|
||||||
|
for i in $(seq 1 "$COUNT"); do
|
||||||
|
R=$(cat "${TMP}/invoke-$i" 2>/dev/null || echo "$i:000")
|
||||||
|
C=$(echo "$R" | cut -d: -f2)
|
||||||
|
if [ "$C" = "200" ]; then
|
||||||
|
INVOKE_OK=$((INVOKE_OK+1))
|
||||||
|
else
|
||||||
|
echo "INVOKE_FAIL user$i http=$C"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "INVOKE_OK=$INVOKE_OK/$COUNT"
|
||||||
|
echo "=== MULTIUSER TEST END ==="
|
||||||
|
|
||||||
|
[ "$INIT_OK" = "$COUNT" ]
|
||||||
|
[ "$CREATE_OK" = "$COUNT" ]
|
||||||
|
[ "$INVOKE_OK" = "$COUNT" ]
|
||||||
Executable
+119
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE="https://fission.kube5s.ru/console/api"
|
||||||
|
COUNT=${1:-5}
|
||||||
|
RUN_ID="journey$(date +%s | tail -c 6)"
|
||||||
|
ROOT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
NS_CLEANUP_SCRIPT="${ROOT_DIR}/cleanup_test_namespaces.sh"
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
|
||||||
|
declare -a USERS=()
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$TMP" 2>/dev/null || true
|
||||||
|
if [ -x "$NS_CLEANUP_SCRIPT" ] && [ ${#USERS[@]} -gt 0 ]; then
|
||||||
|
KUBECTL_DIRECT=1 "$NS_CLEANUP_SCRIPT" "${USERS[@]}" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
json_field() {
|
||||||
|
local payload="$1"
|
||||||
|
local key="$2"
|
||||||
|
printf '%s' "$payload" | python3 -c 'import json,sys
|
||||||
|
key=sys.argv[1]
|
||||||
|
try:
|
||||||
|
data=json.load(sys.stdin)
|
||||||
|
value=data.get(key, "")
|
||||||
|
print(value if not isinstance(value, (dict,list)) else json.dumps(value))
|
||||||
|
except Exception:
|
||||||
|
print("")' "$key"
|
||||||
|
}
|
||||||
|
|
||||||
|
json_escape() {
|
||||||
|
python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'
|
||||||
|
}
|
||||||
|
|
||||||
|
create_call() {
|
||||||
|
local sub="$1"
|
||||||
|
local body="$2"
|
||||||
|
curl -s -w "\n%{http_code}" --max-time 60 -X POST "${BASE}/functions" \
|
||||||
|
-H "X-Test-Sub: ${sub}" -H 'Content-Type: application/json' -d "$body"
|
||||||
|
}
|
||||||
|
|
||||||
|
invoke_call() {
|
||||||
|
local sub="$1"
|
||||||
|
local name="$2"
|
||||||
|
curl -s --max-time 45 -X POST "${BASE}/functions/${name}/invoke" \
|
||||||
|
-H "X-Test-Sub: ${sub}" -H 'Content-Type: application/json' -d '{}' || true
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== USER JOURNEYS START run=$RUN_ID count=$COUNT ==="
|
||||||
|
|
||||||
|
for i in $(seq 1 "$COUNT"); do
|
||||||
|
USERS+=("${RUN_ID}-u${i}@test.local")
|
||||||
|
(
|
||||||
|
U="${RUN_ID}-u${i}@test.local"
|
||||||
|
FN="journey-${RUN_ID}-${i}"
|
||||||
|
RESULT="PASS"
|
||||||
|
|
||||||
|
AUTH=$(curl -s -w "\n%{http_code}" --max-time 45 -X POST "${BASE}/auth" -H 'Content-Type: application/json' -d "{\"token\":\"${U}\",\"env\":\"test\"}")
|
||||||
|
AUTH_CODE=$(printf '%s' "$AUTH" | tail -1)
|
||||||
|
[ "$AUTH_CODE" = "200" ] || RESULT="FAIL auth=${AUTH_CODE}"
|
||||||
|
|
||||||
|
BAD=$(create_call "$U" '{"name":"bad-lang","language":"cobol","code":"x"}')
|
||||||
|
BAD_CODE=$(printf '%s' "$BAD" | tail -1)
|
||||||
|
[ "$BAD_CODE" = "400" ] || RESULT="FAIL bad_create=${BAD_CODE}"
|
||||||
|
|
||||||
|
CODE_TEXT=$(printf 'def main():\n return "journey-ok-%s-%s"\n' "$RUN_ID" "$i")
|
||||||
|
CODE_JSON=$(printf '%s' "$CODE_TEXT" | json_escape)
|
||||||
|
GOOD=$(create_call "$U" "{\"name\":\"${FN}\",\"language\":\"python\",\"code\":${CODE_JSON}}")
|
||||||
|
GOOD_CODE=$(printf '%s' "$GOOD" | tail -1)
|
||||||
|
[ "$GOOD_CODE" = "201" ] || RESULT="FAIL create=${GOOD_CODE}"
|
||||||
|
|
||||||
|
DUP=$(create_call "$U" "{\"name\":\"${FN}\",\"language\":\"python\",\"code\":${CODE_JSON}}")
|
||||||
|
DUP_CODE=$(printf '%s' "$DUP" | tail -1)
|
||||||
|
{ [ "$DUP_CODE" = "409" ] || [ "$DUP_CODE" = "502" ]; } || RESULT="FAIL dup=${DUP_CODE}"
|
||||||
|
|
||||||
|
INV_STATUS=""
|
||||||
|
INV_RAW=""
|
||||||
|
for try in 1 2 3 4 5 6; do
|
||||||
|
INV=$(invoke_call "$U" "$FN")
|
||||||
|
INV_STATUS=$(json_field "$INV" status)
|
||||||
|
INV_RAW=$(json_field "$INV" response_raw)
|
||||||
|
echo "USER=$i TRY=$try status=$INV_STATUS raw=$INV_RAW"
|
||||||
|
if [ "$INV_STATUS" = "200" ] && printf '%s' "$INV_RAW" | grep -q "journey-ok-${RUN_ID}-${i}"; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
[ "$try" -lt 6 ] && sleep 5
|
||||||
|
done
|
||||||
|
[ "$INV_STATUS" = "200" ] || RESULT="FAIL invoke=${INV_STATUS}"
|
||||||
|
|
||||||
|
DEL1=$(curl -s -o /dev/null -w "%{http_code}" --max-time 45 -X DELETE "${BASE}/functions/${FN}" -H "X-Test-Sub: ${U}")
|
||||||
|
[ "$DEL1" = "200" ] || RESULT="FAIL delete1=${DEL1}"
|
||||||
|
DEL2=$(curl -s -o /dev/null -w "%{http_code}" --max-time 45 -X DELETE "${BASE}/functions/${FN}" -H "X-Test-Sub: ${U}")
|
||||||
|
[ "$DEL2" = "404" ] || RESULT="FAIL delete2=${DEL2}"
|
||||||
|
|
||||||
|
echo "$i:$RESULT" > "${TMP}/user-$i"
|
||||||
|
) &
|
||||||
|
done
|
||||||
|
wait
|
||||||
|
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
for i in $(seq 1 "$COUNT"); do
|
||||||
|
R=$(cat "${TMP}/user-$i" 2>/dev/null || echo "$i:FAIL missing_result")
|
||||||
|
STATUS=$(printf '%s' "$R" | cut -d: -f2-)
|
||||||
|
if [ "$STATUS" = "PASS" ]; then
|
||||||
|
PASS=$((PASS+1))
|
||||||
|
echo "USER_PASS $i"
|
||||||
|
else
|
||||||
|
FAIL=$((FAIL+1))
|
||||||
|
echo "USER_FAIL $i $STATUS"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "USER_JOURNEYS_PASS=$PASS/$COUNT"
|
||||||
|
echo "=== USER JOURNEYS END ==="
|
||||||
|
[ "$FAIL" = 0 ]
|
||||||
@@ -57,14 +57,6 @@ chk H03 php fail $'<?php\nfunction handler(array $ctx): array {\n $name = $ct
|
|||||||
chk H04 php fail $'<?php\n$x = @@@;'
|
chk H04 php fail $'<?php\n$x = @@@;'
|
||||||
chk H05 php fail $'<?php\nfunction foo( { return 1; }'
|
chk H05 php fail $'<?php\nfunction foo( { return 1; }'
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== PERL ==="
|
|
||||||
chk L01 perl ok 'sub handler { my ($env) = @_; return [200, [], ["ok"]]; }'
|
|
||||||
chk L02 perl ok 'sub handler { my ($e) = @_; my $name = "World"; return [200, ["Content-Type", "text/plain"], [$name]]; }'
|
|
||||||
chk L03 perl fail $'sub handler { my $e = @_\n return [200]; }'
|
|
||||||
chk L04 perl fail 'sub handler { my ($e) = @_; return [200,, []]; }'
|
|
||||||
chk L05 perl fail 'sub handler { ('
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== GO ==="
|
echo "=== GO ==="
|
||||||
chk G01 go ok $'package main\nimport "net/http"\nfunc Handler(w http.ResponseWriter, r *http.Request) {}'
|
chk G01 go ok $'package main\nimport "net/http"\nfunc Handler(w http.ResponseWriter, r *http.Request) {}'
|
||||||
@@ -79,7 +71,6 @@ chk E01 nodejs fail ''
|
|||||||
chk E02 python fail 'asd @@@'
|
chk E02 python fail 'asd @@@'
|
||||||
chk E03 ruby fail 'def foo(;;; end'
|
chk E03 ruby fail 'def foo(;;; end'
|
||||||
chk E04 php fail '<?php echo @@@'
|
chk E04 php fail '<?php echo @@@'
|
||||||
chk E05 perl fail '@@@'
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "========================"
|
echo "========================"
|
||||||
|
|||||||
@@ -256,31 +256,6 @@ check H03 "нет функции handler в PHP" php fail \
|
|||||||
function process(array $data): array {
|
function process(array $data): array {
|
||||||
return ["status" => 200];
|
return ["status" => 200];
|
||||||
}'
|
}'
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "--- PERL ---"
|
|
||||||
|
|
||||||
# CORRECT
|
|
||||||
check L01 "правильный Perl handler" perl ok \
|
|
||||||
'sub handler {
|
|
||||||
my ($env) = @_;
|
|
||||||
return [200, ["Content-Type" => "text/plain"], ["Hello World"]];
|
|
||||||
}'
|
|
||||||
|
|
||||||
# SYNTAX ERROR
|
|
||||||
check L02 "синтаксическая ошибка Perl" perl fail \
|
|
||||||
'sub handler {
|
|
||||||
my $env = @_
|
|
||||||
return [200, [], ["ok"]];
|
|
||||||
}'
|
|
||||||
|
|
||||||
# NO ENTRYPOINT
|
|
||||||
check L03 "нет sub handler в Perl" perl fail \
|
|
||||||
'sub process {
|
|
||||||
my ($env) = @_;
|
|
||||||
return [200, [], ["ok"]];
|
|
||||||
}'
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "--- ГРАНИЧНЫЕ СЛУЧАИ ---"
|
echo "--- ГРАНИЧНЫЕ СЛУЧАИ ---"
|
||||||
|
|
||||||
|
|||||||
Regular → Executable
+33
-6
@@ -13,12 +13,36 @@
|
|||||||
|
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
SSH="ssh -i /home/naeel/remote_dev/common/id_ed25519.txt -o StrictHostKeyChecking=no naeel@5.172.178.213"
|
if [ "${KUBECTL_DIRECT:-0}" = "1" ]; then
|
||||||
|
EXECUTOR_LOGS_CMD() {
|
||||||
|
kubectl logs -n fission deployment/executor 2>/dev/null
|
||||||
|
}
|
||||||
|
else
|
||||||
|
SSH="ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213"
|
||||||
|
EXECUTOR_LOGS_CMD() {
|
||||||
|
$SSH "kubectl logs -n fission deployment/executor 2>/dev/null"
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
BASE="https://fission.kube5s.ru/console/api"
|
BASE="https://fission.kube5s.ru/console/api"
|
||||||
COUNT=${1:-10}
|
COUNT=${1:-10}
|
||||||
RUN_ID="mt$(date +%s | tail -c 6)"
|
RUN_ID="mt$(date +%s | tail -c 6)"
|
||||||
|
ROOT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
NS_CLEANUP_SCRIPT="${ROOT_DIR}/cleanup_test_namespaces.sh"
|
||||||
PASS=0; FAIL=0
|
PASS=0; FAIL=0
|
||||||
|
|
||||||
|
declare -a CREATED_USERS=()
|
||||||
|
|
||||||
|
cleanup_namespaces() {
|
||||||
|
rm -rf "${TMPDIR_RESULTS:-}" 2>/dev/null || true
|
||||||
|
if [ -x "$NS_CLEANUP_SCRIPT" ] && [ "${#CREATED_USERS[@]}" -gt 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo ">>> Cleanup test namespaces..."
|
||||||
|
"$NS_CLEANUP_SCRIPT" "${CREATED_USERS[@]}" || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup_namespaces EXIT
|
||||||
|
|
||||||
green() { echo -e "\033[32m PASS: $1\033[0m"; PASS=$((PASS+1)); }
|
green() { echo -e "\033[32m PASS: $1\033[0m"; PASS=$((PASS+1)); }
|
||||||
red() { echo -e "\033[31m FAIL: $1\033[0m"; FAIL=$((FAIL+1)); }
|
red() { echo -e "\033[31m FAIL: $1\033[0m"; FAIL=$((FAIL+1)); }
|
||||||
|
|
||||||
@@ -40,6 +64,7 @@ TMPDIR_RESULTS=$(mktemp -d)
|
|||||||
|
|
||||||
for i in $(seq 1 $COUNT); do
|
for i in $(seq 1 $COUNT); do
|
||||||
USER="${RUN_ID}-user${i}@test.local"
|
USER="${RUN_ID}-user${i}@test.local"
|
||||||
|
CREATED_USERS+=("$USER")
|
||||||
(
|
(
|
||||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 30 \
|
CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 30 \
|
||||||
-X GET "${BASE}/functions" \
|
-X GET "${BASE}/functions" \
|
||||||
@@ -77,13 +102,13 @@ echo ">>> [2/4] Проверяем регистрацию NS в executor..."
|
|||||||
# Ждём немного — informer почти мгновенный, но логи могут задержаться
|
# Ждём немного — informer почти мгновенный, но логи могут задержаться
|
||||||
sleep 5
|
sleep 5
|
||||||
|
|
||||||
EXEC_LOGS=$($SSH "kubectl logs -n fission deployment/executor 2>/dev/null | grep 'registered namespace'" 2>/dev/null || echo "")
|
EXEC_LOGS=$(EXECUTOR_LOGS_CMD | grep 'registered namespace' 2>/dev/null || echo "")
|
||||||
REGISTERED=0
|
REGISTERED=0
|
||||||
for i in $(seq 1 $COUNT); do
|
for i in $(seq 1 $COUNT); do
|
||||||
USER="${USERS[$i]:-}"
|
USER="${USERS[$i]:-}"
|
||||||
# NS имя формируется консолью как hash от email
|
# NS имя формируется консолью как hash от email
|
||||||
# Ищем любые новые registered namespace в логах после старта теста
|
# Ищем любые новые registered namespace в логах после старта теста
|
||||||
REGISTERED=$($SSH "kubectl logs -n fission deployment/executor 2>/dev/null | grep 'registered namespace' | wc -l" 2>/dev/null || echo 0)
|
REGISTERED=$(EXECUTOR_LOGS_CMD | grep 'registered namespace' | wc -l 2>/dev/null || echo 0)
|
||||||
done
|
done
|
||||||
|
|
||||||
echo " Всего registered namespace событий в executor: $REGISTERED"
|
echo " Всего registered namespace событий в executor: $REGISTERED"
|
||||||
@@ -103,6 +128,10 @@ echo ">>> [3/4] Создаём функции для каждого пользо
|
|||||||
PYTHON_CODE='def main():
|
PYTHON_CODE='def main():
|
||||||
return "ok-'"${RUN_ID}"'"'
|
return "ok-'"${RUN_ID}"'"'
|
||||||
|
|
||||||
|
json_escape() {
|
||||||
|
python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'
|
||||||
|
}
|
||||||
|
|
||||||
for i in $(seq 1 $COUNT); do
|
for i in $(seq 1 $COUNT); do
|
||||||
USER="${USERS[$i]:-${RUN_ID}-user${i}@test.local}"
|
USER="${USERS[$i]:-${RUN_ID}-user${i}@test.local}"
|
||||||
FN_NAME="testfn-${RUN_ID}"
|
FN_NAME="testfn-${RUN_ID}"
|
||||||
@@ -112,7 +141,7 @@ for i in $(seq 1 $COUNT); do
|
|||||||
-X POST "${BASE}/functions" \
|
-X POST "${BASE}/functions" \
|
||||||
-H "X-Test-Sub: ${USER}" \
|
-H "X-Test-Sub: ${USER}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"name\":\"${FN_NAME}\",\"language\":\"python\",\"code\":\"${PYTHON_CODE}\"}" \
|
-d "{\"name\":\"${FN_NAME}\",\"language\":\"python\",\"code\":$(printf '%s' \"${PYTHON_CODE}\" | json_escape)}" \
|
||||||
2>/dev/null)
|
2>/dev/null)
|
||||||
CODE=$(echo "$CREATE" | tail -1)
|
CODE=$(echo "$CREATE" | tail -1)
|
||||||
echo "$i:$CODE" > "${TMPDIR_RESULTS}/create-${i}"
|
echo "$i:$CODE" > "${TMPDIR_RESULTS}/create-${i}"
|
||||||
@@ -177,8 +206,6 @@ else
|
|||||||
red "$INVOKE_FAIL / $COUNT функций не ответили (возможно cold start — повторить тест)"
|
red "$INVOKE_FAIL / $COUNT функций не ответили (возможно cold start — повторить тест)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
rm -rf "$TMPDIR_RESULTS"
|
|
||||||
|
|
||||||
# ── Итог ─────────────────────────────────────────────────────────────────────
|
# ── Итог ─────────────────────────────────────────────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
|
|||||||
Executable
+248
@@ -0,0 +1,248 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE="https://fission.kube5s.ru/console/api"
|
||||||
|
ROOT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
NS_CLEANUP_SCRIPT="${ROOT_DIR}/cleanup_test_namespaces.sh"
|
||||||
|
RUN_ID="ui$(date +%s | tail -c 6)"
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
LOGIN_SUB="ui-login-${RUN_ID}@test.local"
|
||||||
|
LANG_SUB="ui-lang-${RUN_ID}@test.local"
|
||||||
|
NODE_SUB="ui-node-${RUN_ID}@test.local"
|
||||||
|
STACK_SUB="ui-stack-${RUN_ID}@test.local"
|
||||||
|
NEG_SUB="ui-neg-${RUN_ID}@test.local"
|
||||||
|
|
||||||
|
pass() { PASS=$((PASS+1)); echo " PASS: $1"; }
|
||||||
|
fail() { FAIL=$((FAIL+1)); echo " FAIL: $1"; }
|
||||||
|
|
||||||
|
json_quote() {
|
||||||
|
python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
json_field() {
|
||||||
|
local payload="$1"
|
||||||
|
local key="$2"
|
||||||
|
printf '%s' "$payload" | python3 -c 'import json,sys
|
||||||
|
key=sys.argv[1]
|
||||||
|
try:
|
||||||
|
data=json.load(sys.stdin)
|
||||||
|
value=data.get(key, "")
|
||||||
|
print(value if not isinstance(value, (dict,list)) else json.dumps(value))
|
||||||
|
except Exception:
|
||||||
|
print("")' "$key"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_namespaces() {
|
||||||
|
if [ -x "$NS_CLEANUP_SCRIPT" ]; then
|
||||||
|
echo ""
|
||||||
|
echo ">>> Cleanup UI test namespaces..."
|
||||||
|
KUBECTL_DIRECT=1 "$NS_CLEANUP_SCRIPT" "$LOGIN_SUB" "$LANG_SUB" "$NODE_SUB" "$STACK_SUB" "$NEG_SUB" || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup_namespaces EXIT
|
||||||
|
|
||||||
|
auth_call() {
|
||||||
|
local token="$1"
|
||||||
|
local env_name="${2:-test}"
|
||||||
|
curl -s -w "\n%{http_code}" --max-time 45 -X POST "${BASE}/auth" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"token\":\"${token}\",\"env\":\"${env_name}\"}"
|
||||||
|
}
|
||||||
|
|
||||||
|
create_call() {
|
||||||
|
local sub="$1"
|
||||||
|
local body="$2"
|
||||||
|
curl -s -w "\n%{http_code}" --max-time 60 -X POST "${BASE}/functions" \
|
||||||
|
-H "X-Test-Sub: ${sub}" -H 'Content-Type: application/json' -d "$body"
|
||||||
|
}
|
||||||
|
|
||||||
|
invoke_call() {
|
||||||
|
local sub="$1"
|
||||||
|
local name="$2"
|
||||||
|
local body="${3:-{}}"
|
||||||
|
local out_file err_file http_code rc resp err_text
|
||||||
|
out_file=$(mktemp)
|
||||||
|
err_file=$(mktemp)
|
||||||
|
rc=0
|
||||||
|
http_code=$(curl -sS -o "$out_file" -w "%{http_code}" --max-time 60 -X POST "${BASE}/functions/${name}/invoke" \
|
||||||
|
-H "X-Test-Sub: ${sub}" -H 'Content-Type: application/json' -d "$body" 2>"$err_file") || rc=$?
|
||||||
|
resp=$(cat "$out_file" 2>/dev/null || true)
|
||||||
|
err_text=$(tr '\n' ' ' <"$err_file" | sed 's/[[:space:]]\+/ /g')
|
||||||
|
rm -f "$out_file" "$err_file"
|
||||||
|
|
||||||
|
if [ "$rc" -ne 0 ]; then
|
||||||
|
printf '{"error":%s,"status":"","response_raw":"","http_status":"%s"}\n' \
|
||||||
|
"$(json_quote "curl rc=${rc} http=${http_code}: ${err_text}")" "$http_code"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if printf '%s' "$resp" | python3 -c 'import json,sys; json.load(sys.stdin)' >/dev/null 2>&1; then
|
||||||
|
printf '%s' "$resp"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '{"error":%s,"status":"","response_raw":%s,"http_status":"%s"}\n' \
|
||||||
|
"$(json_quote "non-json invoke response http=${http_code}")" "$(json_quote "$resp")" "$http_code"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_invoke_ok() {
|
||||||
|
local sub="$1"
|
||||||
|
local name="$2"
|
||||||
|
local expected="$3"
|
||||||
|
local tries="$4"
|
||||||
|
local delay="$5"
|
||||||
|
local try status raw err latency http_status
|
||||||
|
for try in $(seq 1 "$tries"); do
|
||||||
|
resp=$(invoke_call "$sub" "$name")
|
||||||
|
status=$(json_field "$resp" status)
|
||||||
|
raw=$(json_field "$resp" response_raw)
|
||||||
|
err=$(json_field "$resp" error)
|
||||||
|
latency=$(json_field "$resp" latency_ms)
|
||||||
|
http_status=$(json_field "$resp" http_status)
|
||||||
|
echo " TRY=${try} fn=${name} status=${status} http=${http_status} latency_ms=${latency} raw=${raw} err=${err}"
|
||||||
|
if [ "$status" = "200" ] && printf '%s' "$raw" | grep -q "$expected"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "$try" -lt "$tries" ]; then
|
||||||
|
sleep "$delay"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
echo " UI-like user flow tests"
|
||||||
|
echo " Run ID: ${RUN_ID}"
|
||||||
|
echo "========================================"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ">>> T01: login с невалидным токеном"
|
||||||
|
R=$(auth_call "not-an-email")
|
||||||
|
CODE=$(printf '%s' "$R" | tail -1)
|
||||||
|
[ "$CODE" = "401" ] && pass "login invalid token -> 401" || fail "login invalid token -> got $CODE"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ">>> T02: login c валидным test token"
|
||||||
|
R=$(auth_call "$LOGIN_SUB")
|
||||||
|
CODE=$(printf '%s' "$R" | tail -1)
|
||||||
|
BODY=$(printf '%s' "$R" | sed '$d')
|
||||||
|
NS=$(json_field "$BODY" namespace)
|
||||||
|
[ "$CODE" = "200" ] && pass "login valid token -> 200" || fail "login valid token -> got $CODE"
|
||||||
|
printf '%s' "$NS" | grep -q '^fission-' && pass "login returns namespace" || fail "login namespace malformed: ${NS}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ">>> T03: ошибки create, которые типичны для UI"
|
||||||
|
R=$(create_call "$NEG_SUB" '{"name":"","language":"python","code":"def main():\n return 1"}')
|
||||||
|
CODE=$(printf '%s' "$R" | tail -1)
|
||||||
|
[ "$CODE" = "400" ] && pass "empty name -> 400" || fail "empty name -> got $CODE"
|
||||||
|
|
||||||
|
R=$(create_call "$NEG_SUB" '{"name":"empty-code","language":"python","code":" "}')
|
||||||
|
CODE=$(printf '%s' "$R" | tail -1)
|
||||||
|
[ "$CODE" = "400" ] && pass "empty code -> 400" || fail "empty code -> got $CODE"
|
||||||
|
|
||||||
|
R=$(create_call "$NEG_SUB" '{"name":"bad ttl","language":"python","code":"def main():\n return 1","ttl":"99z"}')
|
||||||
|
CODE=$(printf '%s' "$R" | tail -1)
|
||||||
|
[ "$CODE" = "400" ] && pass "invalid ttl -> 400" || fail "invalid ttl -> got $CODE"
|
||||||
|
|
||||||
|
R=$(create_call "$NEG_SUB" '{"name":"bad name fn","language":"python","code":"def main():\n return 1"}')
|
||||||
|
CODE=$(printf '%s' "$R" | tail -1)
|
||||||
|
[ "$CODE" != "201" ] && [ "$CODE" != "200" ] && pass "invalid name with spaces rejected" || fail "invalid name unexpectedly created"
|
||||||
|
|
||||||
|
R=$(create_call "$NEG_SUB" '{"name":"bad-lang","language":"cobol","code":"x"}')
|
||||||
|
CODE=$(printf '%s' "$R" | tail -1)
|
||||||
|
[ "$CODE" = "400" ] && pass "invalid language -> 400" || fail "invalid language -> got $CODE"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ">>> T04: duplicate create и повторный delete"
|
||||||
|
R=$(create_call "$NEG_SUB" '{"name":"dup-ui","language":"python","entrypoint":"main.main","code":"def main():\n return \"dup-ui\""}')
|
||||||
|
CODE=$(printf '%s' "$R" | tail -1)
|
||||||
|
[ "$CODE" = "201" ] && pass "first create -> 201" || fail "first create -> got $CODE"
|
||||||
|
|
||||||
|
R=$(create_call "$NEG_SUB" '{"name":"dup-ui","language":"python","entrypoint":"main.main","code":"def main():\n return \"dup-ui-2\""}')
|
||||||
|
CODE=$(printf '%s' "$R" | tail -1)
|
||||||
|
([ "$CODE" = "409" ] || [ "$CODE" = "502" ]) && pass "duplicate create rejected" || fail "duplicate create -> got $CODE"
|
||||||
|
|
||||||
|
DC=$(curl -s -o /dev/null -w "%{http_code}" --max-time 45 -X DELETE "${BASE}/functions/dup-ui" -H "X-Test-Sub: ${NEG_SUB}")
|
||||||
|
[ "$DC" = "200" ] && pass "first delete -> 200" || fail "first delete -> got $DC"
|
||||||
|
DC2=$(curl -s -o /dev/null -w "%{http_code}" --max-time 45 -X DELETE "${BASE}/functions/dup-ui" -H "X-Test-Sub: ${NEG_SUB}")
|
||||||
|
[ "$DC2" = "404" ] && pass "second delete -> 404" || fail "second delete -> got $DC2"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ">>> T05: Python create/invoke как из UI"
|
||||||
|
R=$(create_call "$LANG_SUB" '{"name":"py-ui","language":"python","entrypoint":"main.main","code":"def main():\n return \"py-ui-ok\""}')
|
||||||
|
CODE=$(printf '%s' "$R" | tail -1)
|
||||||
|
[ "$CODE" = "201" ] && pass "python create -> 201" || fail "python create -> got $CODE"
|
||||||
|
if wait_invoke_ok "$LANG_SUB" "py-ui" "py-ui-ok" 8 5; then
|
||||||
|
pass "python invoke -> 200"
|
||||||
|
else
|
||||||
|
fail "python invoke did not stabilize"
|
||||||
|
fi
|
||||||
|
|
||||||
|
PY_PAR1=$(invoke_call "$LANG_SUB" "py-ui")
|
||||||
|
PY_PAR2=$(invoke_call "$LANG_SUB" "py-ui")
|
||||||
|
[ "$(json_field "$PY_PAR1" status)" = "200" ] && [ "$(json_field "$PY_PAR2" status)" = "200" ] && pass "python double invoke -> both 200" || fail "python double invoke did not return two 200s"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ">>> T06: NodeJS create/invoke и edit/update"
|
||||||
|
R=$(create_call "$NODE_SUB" '{"name":"node-ui","language":"nodejs","entrypoint":"main","code":"module.exports = async function() { return { status: 200, body: \"node-ui-ok\" }; }"}')
|
||||||
|
CODE=$(printf '%s' "$R" | tail -1)
|
||||||
|
[ "$CODE" = "201" ] && pass "nodejs create -> 201" || fail "nodejs create -> got $CODE"
|
||||||
|
if wait_invoke_ok "$NODE_SUB" "node-ui" "node-ui-ok" 16 5; then
|
||||||
|
pass "nodejs invoke -> 200"
|
||||||
|
else
|
||||||
|
fail "nodejs invoke did not stabilize"
|
||||||
|
fi
|
||||||
|
UPD=$(curl -s -w "\n%{http_code}" --max-time 60 -X PUT "${BASE}/functions/node-ui/code" \
|
||||||
|
-H "X-Test-Sub: ${NODE_SUB}" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"code":"module.exports = async function() { return { status: 200, body: \"node-ui-updated\" }; }"}')
|
||||||
|
CODE=$(printf '%s' "$UPD" | tail -1)
|
||||||
|
[ "$CODE" = "200" ] && pass "nodejs update -> 200" || fail "nodejs update -> got $CODE"
|
||||||
|
if wait_invoke_ok "$NODE_SUB" "node-ui" "node-ui-updated" 16 5; then
|
||||||
|
pass "nodejs invoke after edit -> updated body"
|
||||||
|
else
|
||||||
|
fail "nodejs invoke after edit did not return updated body"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ">>> T07: PHP и Ruby в одном namespace"
|
||||||
|
PHP_CODE=$(python3 -c 'import json; print(json.dumps("<?php\nfunction handler($context) {\n $context[\"response\"]->getBody()->write(\"php-ui-ok\");\n}"))')
|
||||||
|
RUBY_CODE=$(python3 -c 'import json; print(json.dumps("def handler\n \"ruby-ui-ok\"\nend"))')
|
||||||
|
|
||||||
|
R=$(create_call "$STACK_SUB" "{\"name\":\"php-ui\",\"language\":\"php\",\"entrypoint\":\"main.php::handler\",\"code\":${PHP_CODE}}")
|
||||||
|
CODE=$(printf '%s' "$R" | tail -1)
|
||||||
|
[ "$CODE" = "201" ] && pass "php create -> 201" || fail "php create -> got $CODE"
|
||||||
|
if wait_invoke_ok "$STACK_SUB" "php-ui" "php-ui-ok" 24 10; then
|
||||||
|
pass "php invoke -> 200"
|
||||||
|
else
|
||||||
|
fail "php invoke did not stabilize"
|
||||||
|
fi
|
||||||
|
|
||||||
|
R=$(create_call "$STACK_SUB" "{\"name\":\"ruby-ui\",\"language\":\"ruby\",\"entrypoint\":\"handler\",\"code\":${RUBY_CODE}}")
|
||||||
|
CODE=$(printf '%s' "$R" | tail -1)
|
||||||
|
[ "$CODE" = "201" ] && pass "ruby create -> 201" || fail "ruby create -> got $CODE"
|
||||||
|
if wait_invoke_ok "$STACK_SUB" "ruby-ui" "ruby-ui-ok" 24 10; then
|
||||||
|
pass "ruby invoke -> 200"
|
||||||
|
else
|
||||||
|
fail "ruby invoke did not stabilize"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ">>> T08: плохой entrypoint как пользовательский промах"
|
||||||
|
R=$(create_call "$NEG_SUB" '{"name":"node-bad-entry","language":"nodejs","entrypoint":"missing","code":"module.exports = async function() { return { status: 200, body: \"bad\" }; }"}')
|
||||||
|
CODE=$(printf '%s' "$R" | tail -1)
|
||||||
|
[ "$CODE" = "201" ] && pass "bad entrypoint function created" || fail "bad entrypoint create -> got $CODE"
|
||||||
|
RESP=$(invoke_call "$NEG_SUB" "node-bad-entry")
|
||||||
|
STATUS=$(json_field "$RESP" status)
|
||||||
|
ERR=$(json_field "$RESP" error)
|
||||||
|
[ "$STATUS" != "200" ] && pass "bad entrypoint invoke fails as expected" || fail "bad entrypoint unexpectedly invoked"
|
||||||
|
printf '%s' "$ERR" | grep -Eiq 'timeout|specialization|error|not found' && pass "bad entrypoint error is visible" || fail "bad entrypoint error text weak: ${ERR}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
echo " RESULT: PASS=${PASS} FAIL=${FAIL}"
|
||||||
|
echo "========================================"
|
||||||
|
|
||||||
|
[ "$FAIL" -eq 0 ]
|
||||||
+34
-27
@@ -2,10 +2,42 @@
|
|||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
BASE="https://fission.kube5s.ru/console/api"
|
BASE="https://fission.kube5s.ru/console/api"
|
||||||
|
ROOT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
NS_CLEANUP_SCRIPT="${ROOT_DIR}/cleanup_test_namespaces.sh"
|
||||||
PASS=0; FAIL=0
|
PASS=0; FAIL=0
|
||||||
RUN_ID=$(date +%s%N 2>/dev/null || date +%s)$(od -An -N2 -tu2 /dev/urandom 2>/dev/null | tr -d ' ' || echo "$$")
|
RUN_ID=$(date +%s%N 2>/dev/null || date +%s)$(od -An -N2 -tu2 /dev/urandom 2>/dev/null | tr -d ' ' || echo "$$")
|
||||||
RUN_ID=$(echo "$RUN_ID" | sha256sum 2>/dev/null | head -c 8 || echo "${RUN_ID}" | cut -c1-8)
|
RUN_ID=$(echo "$RUN_ID" | sha256sum 2>/dev/null | head -c 8 || echo "${RUN_ID}" | cut -c1-8)
|
||||||
|
|
||||||
|
cleanup_namespaces() {
|
||||||
|
local subs=()
|
||||||
|
subs+=("${GO_NS}")
|
||||||
|
subs+=("alice@test.local" "routetest-alice@test.local" "routetest-bob@test.local")
|
||||||
|
subs+=("val-user@test.local" "dup-user@test.local" "upd-user@test.local" "get-user@test.local")
|
||||||
|
subs+=("ns-u1@test.local" "ns-u2@test.local" "alice-del@test.local" "carol-del@test.local")
|
||||||
|
subs+=("py-user@test.local" "js-user@test.local" "ttl-user@test.local" "body-user@test.local")
|
||||||
|
subs+=("stress-user@test.local" "brand-new-empty-user-xyzqwerty@test.local" "chain-user@test.local")
|
||||||
|
subs+=("dup2-user-${RUN_ID}@test.local" "name-user-${RUN_ID}@test.local")
|
||||||
|
subs+=("len57-user-${RUN_ID}@test.local" "big-user-${RUN_ID}@test.local" "size-user-${RUN_ID}@test.local")
|
||||||
|
subs+=("ttl-exp-${RUN_ID}@test.local" "race-${RUN_ID}@test.local")
|
||||||
|
subs+=("mfunc-${RUN_ID}@test.local" "victim-${RUN_ID}@test.local" "attacker-${RUN_ID}@test.local")
|
||||||
|
subs+=("lang-multi-${RUN_ID}@test.local" "lang-inv-${RUN_ID}@test.local")
|
||||||
|
|
||||||
|
local uid
|
||||||
|
for uid in $(seq 1 10); do
|
||||||
|
subs+=("u${uid}-${RUN_ID}@test.local")
|
||||||
|
done
|
||||||
|
for uid in $(seq 1 3); do
|
||||||
|
subs+=("fullcycle-u${uid}-${RUN_ID}@test.local")
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -x "$NS_CLEANUP_SCRIPT" ]; then
|
||||||
|
echo ""
|
||||||
|
echo ">>> Cleanup test namespaces..."
|
||||||
|
"$NS_CLEANUP_SCRIPT" "${subs[@]}" || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup_namespaces EXIT
|
||||||
|
|
||||||
# GO_NS фиксированный — namespace создаётся один раз, Fission не рестартует при повторных прогонах.
|
# GO_NS фиксированный — namespace создаётся один раз, Fission не рестартует при повторных прогонах.
|
||||||
# Первый прогон занимает ~5 мин (rolling restart Fission), последующие — мгновенно.
|
# Первый прогон занимает ~5 мин (rolling restart Fission), последующие — мгновенно.
|
||||||
GO_NS="go-livetest@test.local"
|
GO_NS="go-livetest@test.local"
|
||||||
@@ -688,9 +720,9 @@ Z_VICTIM_LIST=$(curl -s ${BASE}/functions -H "X-Test-Sub: ${VICTIM_USER}" \
|
|||||||
echo ""
|
echo ""
|
||||||
echo ">>> БЛОК AA: PHP — создание и вызов"
|
echo ">>> БЛОК AA: PHP — создание и вызов"
|
||||||
|
|
||||||
# Все три блока (AA/AB/AC) используют ОДИН namespace, чтобы избежать повторных executor rollouts.
|
# Оба блока (AA/AB) используют ОДИН namespace, чтобы избежать повторных executor rollouts.
|
||||||
# Первая функция (PHP) вызывает addNSToFission → executor rollout (~60s).
|
# Первая функция (PHP) вызывает addNSToFission → executor rollout (~60s).
|
||||||
# Ruby и Perl создаются в том же namespace → rollout не нужен → пулы стартуют сразу.
|
# Ruby создаётся в том же namespace → rollout не нужен → пул стартует сразу.
|
||||||
LANG_NS="lang-multi-${RUN_ID}@test.local"
|
LANG_NS="lang-multi-${RUN_ID}@test.local"
|
||||||
|
|
||||||
PHP_CODE=$(python3 -c "import json; print(json.dumps('<?php\nfunction handler(\$context) {\n \$context[\"response\"]->getBody()->write(\"php-ok\");\n}'))")
|
PHP_CODE=$(python3 -c "import json; print(json.dumps('<?php\nfunction handler(\$context) {\n \$context[\"response\"]->getBody()->write(\"php-ok\");\n}'))")
|
||||||
@@ -739,31 +771,6 @@ done
|
|||||||
[ "$STATUS" = "200" ] && pass "Ruby: invoke → 200" || fail "Ruby: invoke → got $STATUS (body: $RAW)"
|
[ "$STATUS" = "200" ] && pass "Ruby: invoke → 200" || fail "Ruby: invoke → got $STATUS (body: $RAW)"
|
||||||
echo "$RAW" | grep -q "ruby-ok" && pass "Ruby: тело содержит ruby-ok" || fail "Ruby: тело не содержит ruby-ok (got: $RAW)"
|
echo "$RAW" | grep -q "ruby-ok" && pass "Ruby: тело содержит ruby-ok" || fail "Ruby: тело не содержит ruby-ok (got: $RAW)"
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo ">>> БЛОК AC: Perl — создание и вызов"
|
|
||||||
|
|
||||||
PERL_CODE=$(python3 -c "import json; print(json.dumps('sub {\n return \"perl-ok\";\n}'))")
|
|
||||||
R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \
|
|
||||||
-H "X-Test-Sub: ${LANG_NS}" -H "Content-Type: application/json" \
|
|
||||||
-d "{\"name\":\"perl-fn\",\"language\":\"perl\",\"code\":${PERL_CODE},\"ttl\":\"10m\"}")
|
|
||||||
CODE=$(echo "$R" | tail -1)
|
|
||||||
check_http "$CODE" "201" "Perl: create → 201"
|
|
||||||
|
|
||||||
# Ждём пока poolmgr поднимет pod (retry до 200s).
|
|
||||||
# Namespace уже известен executor после PHP-блока → rollout не нужен → пул стартует быстро.
|
|
||||||
STATUS=""; RAW=""
|
|
||||||
for _try in $(seq 1 20); do
|
|
||||||
sleep 10
|
|
||||||
INV=$(curl -s -X POST ${BASE}/functions/perl-fn/invoke \
|
|
||||||
-H "X-Test-Sub: ${LANG_NS}")
|
|
||||||
STATUS=$(echo "$INV" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','?'))" 2>/dev/null)
|
|
||||||
RAW=$(echo "$INV" | python3 -c "import sys,json; print(json.load(sys.stdin).get('response_raw','?'))" 2>/dev/null)
|
|
||||||
[ "$STATUS" = "200" ] && break
|
|
||||||
done
|
|
||||||
[ "$STATUS" = "200" ] && pass "Perl: invoke → 200" || fail "Perl: invoke → got $STATUS (body: $RAW)"
|
|
||||||
echo "$RAW" | grep -q "perl-ok" && pass "Perl: тело содержит perl-ok" || fail "Perl: тело не содержит perl-ok (got: $RAW)"
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
echo ">>> БЛОК AD: Невалидный язык → 400"
|
echo ">>> БЛОК AD: Невалидный язык → 400"
|
||||||
|
|||||||
Reference in New Issue
Block a user