Organize LLM assets and function lifecycle fixes
This commit is contained in:
@@ -49,7 +49,7 @@ spec:
|
||||
serviceAccountName: fission-console
|
||||
containers:
|
||||
- name: console
|
||||
image: naeel/fission-console@sha256:85361f6fbad506845e0e94b2f9a433b11075fb5f478076270d1d2af7bc5258bb
|
||||
image: naeel/fission-console:v1.3.3
|
||||
ports:
|
||||
- containerPort: 8090
|
||||
env:
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
)
|
||||
|
||||
const (
|
||||
functionCreatedAtAnnotation = "fission-console/created-at"
|
||||
functionUpdatedAtAnnotation = "fission-console/updated-at"
|
||||
)
|
||||
|
||||
func ensureFunctionTimestamps(fn *unstructured.Unstructured, now time.Time) {
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
annotations := fn.GetAnnotations()
|
||||
if annotations == nil {
|
||||
annotations = map[string]string{}
|
||||
}
|
||||
if annotations[functionCreatedAtAnnotation] == "" {
|
||||
if created := fn.GetCreationTimestamp(); !created.IsZero() {
|
||||
annotations[functionCreatedAtAnnotation] = created.UTC().Format(time.RFC3339)
|
||||
} else {
|
||||
annotations[functionCreatedAtAnnotation] = now.UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
annotations[functionUpdatedAtAnnotation] = now.UTC().Format(time.RFC3339)
|
||||
fn.SetAnnotations(annotations)
|
||||
}
|
||||
|
||||
func functionTimestampResponse(fn *unstructured.Unstructured) map[string]string {
|
||||
result := map[string]string{}
|
||||
if fn == nil {
|
||||
return result
|
||||
}
|
||||
annotations := fn.GetAnnotations()
|
||||
if annotations == nil {
|
||||
annotations = map[string]string{}
|
||||
}
|
||||
if created := annotations[functionCreatedAtAnnotation]; created != "" {
|
||||
result["created_at"] = created
|
||||
} else if ts := fn.GetCreationTimestamp(); !ts.IsZero() {
|
||||
result["created_at"] = ts.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if updated := annotations[functionUpdatedAtAnnotation]; updated != "" {
|
||||
result["updated_at"] = updated
|
||||
} else if v := result["created_at"]; v != "" {
|
||||
result["updated_at"] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func formatRFC3339Now(now time.Time) string {
|
||||
return now.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func parseRFC3339(value string) (time.Time, error) {
|
||||
return time.Parse(time.RFC3339, value)
|
||||
}
|
||||
|
||||
func mustParseRFC3339(value string) time.Time {
|
||||
parsed, err := parseRFC3339(value)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("parse RFC3339 %q: %v", value, err))
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
)
|
||||
|
||||
func TestEnsureFunctionTimestampsSetsBothOnCreate(t *testing.T) {
|
||||
fn := &unstructured.Unstructured{}
|
||||
now := mustParseRFC3339("2026-04-28T10:11:12Z")
|
||||
|
||||
ensureFunctionTimestamps(fn, now)
|
||||
|
||||
ann := fn.GetAnnotations()
|
||||
if ann[functionCreatedAtAnnotation] != now.Format(time.RFC3339) {
|
||||
t.Fatalf("created_at = %q, want %q", ann[functionCreatedAtAnnotation], now.Format(time.RFC3339))
|
||||
}
|
||||
if ann[functionUpdatedAtAnnotation] != now.Format(time.RFC3339) {
|
||||
t.Fatalf("updated_at = %q, want %q", ann[functionUpdatedAtAnnotation], now.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureFunctionTimestampsPreservesExistingCreatedAt(t *testing.T) {
|
||||
fn := &unstructured.Unstructured{}
|
||||
created := "2026-04-27T09:10:11Z"
|
||||
annotations := map[string]string{functionCreatedAtAnnotation: created}
|
||||
fn.SetAnnotations(annotations)
|
||||
now := mustParseRFC3339("2026-04-28T10:11:12Z")
|
||||
|
||||
ensureFunctionTimestamps(fn, now)
|
||||
|
||||
ann := fn.GetAnnotations()
|
||||
if ann[functionCreatedAtAnnotation] != created {
|
||||
t.Fatalf("created_at = %q, want %q", ann[functionCreatedAtAnnotation], created)
|
||||
}
|
||||
if ann[functionUpdatedAtAnnotation] != now.Format(time.RFC3339) {
|
||||
t.Fatalf("updated_at = %q, want %q", ann[functionUpdatedAtAnnotation], now.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureFunctionTimestampsBackfillsCreatedAtFromCreationTimestamp(t *testing.T) {
|
||||
fn := &unstructured.Unstructured{}
|
||||
fn.SetCreationTimestamp(metav1.NewTime(mustParseRFC3339("2026-04-26T08:09:10Z")))
|
||||
now := mustParseRFC3339("2026-04-28T10:11:12Z")
|
||||
|
||||
ensureFunctionTimestamps(fn, now)
|
||||
|
||||
ann := fn.GetAnnotations()
|
||||
if ann[functionCreatedAtAnnotation] != "2026-04-26T08:09:10Z" {
|
||||
t.Fatalf("created_at = %q", ann[functionCreatedAtAnnotation])
|
||||
}
|
||||
if ann[functionUpdatedAtAnnotation] != now.Format(time.RFC3339) {
|
||||
t.Fatalf("updated_at = %q, want %q", ann[functionUpdatedAtAnnotation], now.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFunctionTimestampResponse(t *testing.T) {
|
||||
fn := &unstructured.Unstructured{}
|
||||
annotations := map[string]string{
|
||||
functionCreatedAtAnnotation: "2026-04-26T08:09:10Z",
|
||||
functionUpdatedAtAnnotation: "2026-04-28T10:11:12Z",
|
||||
}
|
||||
fn.SetAnnotations(annotations)
|
||||
|
||||
resp := functionTimestampResponse(fn)
|
||||
if resp["created_at"] != "2026-04-26T08:09:10Z" {
|
||||
t.Fatalf("created_at = %q", resp["created_at"])
|
||||
}
|
||||
if resp["updated_at"] != "2026-04-28T10:11:12Z" {
|
||||
t.Fatalf("updated_at = %q", resp["updated_at"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFunctionTimestampResponseFallsBackToCreationTimestamp(t *testing.T) {
|
||||
fn := &unstructured.Unstructured{}
|
||||
fn.SetCreationTimestamp(metav1.NewTime(mustParseRFC3339("2026-04-26T08:09:10Z")))
|
||||
|
||||
resp := functionTimestampResponse(fn)
|
||||
if resp["created_at"] != "2026-04-26T08:09:10Z" {
|
||||
t.Fatalf("created_at = %q", resp["created_at"])
|
||||
}
|
||||
if resp["updated_at"] != "2026-04-26T08:09:10Z" {
|
||||
t.Fatalf("updated_at = %q", resp["updated_at"])
|
||||
}
|
||||
}
|
||||
@@ -354,6 +354,9 @@ func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
|
||||
fnAnnotations := map[string]any{
|
||||
"fission-console/language": req.Language,
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
fnAnnotations[functionCreatedAtAnnotation] = now.Format(time.RFC3339)
|
||||
fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339)
|
||||
if req.TTL != "" {
|
||||
expiresAt, ttlErr := parseTTL(req.TTL)
|
||||
if ttlErr != nil {
|
||||
@@ -381,7 +384,7 @@ func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
|
||||
"kind": "Function",
|
||||
"metadata": map[string]any{"name": req.Name, "namespace": ns, "annotations": fnAnnotations},
|
||||
"spec": map[string]any{
|
||||
"environment": map[string]any{"name": req.Environment, "namespace": ns},
|
||||
"environment": map[string]any{"name": req.Environment, "namespace": ns},
|
||||
"functionTimeout": req.Timeout,
|
||||
"InvokeStrategy": map[string]any{
|
||||
"ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"},
|
||||
@@ -490,6 +493,8 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name
|
||||
"package": packageName,
|
||||
"entrypoint": entrypoint,
|
||||
"timeout": functionTimeout,
|
||||
"created_at": functionTimestampResponse(fn)["created_at"],
|
||||
"updated_at": functionTimestampResponse(fn)["updated_at"],
|
||||
"code": code,
|
||||
"route": route,
|
||||
"methods": methods,
|
||||
@@ -498,11 +503,9 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name
|
||||
}
|
||||
|
||||
// handleUpdateFunctionCode обновляет код уже существующей функции.
|
||||
// Обновляет Package.spec.deployment.literal и синхронизирует resourceVersion в Function.
|
||||
//
|
||||
// Почему нужно обновлять resourceVersion в Function:
|
||||
// Fission executor кэширует Package по resourceVersion. Без обновления в Function
|
||||
// executor будет использовать старый код до перезапуска pod.
|
||||
// Создаёт НОВЫЙ Package (вместо обновления старого) чтобы executor сбросил кэш:
|
||||
// executor кэширует function service по functionUid и не видит изменений в том же Package.
|
||||
// Новое имя пакета гарантирует cache miss в executor.
|
||||
func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request, name string) {
|
||||
var req model.UpdateCodeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -529,21 +532,7 @@ func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
pkgName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
|
||||
if pkgName == "" {
|
||||
writeJSONError(w, http.StatusBadGateway, "function has no package reference")
|
||||
return
|
||||
}
|
||||
|
||||
pkg, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, pkgName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
status := http.StatusBadGateway
|
||||
if apierrors.IsNotFound(err) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
writeJSONError(w, status, fmt.Sprintf("get package %q: %v", pkgName, err))
|
||||
return
|
||||
}
|
||||
oldPkgName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
|
||||
|
||||
// Определяем язык из аннотации — нужен для правильной упаковки
|
||||
lang, _, _ := unstructured.NestedString(fn.Object, "metadata", "annotations", "fission-console/language")
|
||||
@@ -553,38 +542,83 @@ func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
literal := base64.StdEncoding.EncodeToString(deployBytes)
|
||||
if err := unstructured.SetNestedField(pkg.Object, literal, "spec", "deployment", "literal"); err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set package literal: %v", err))
|
||||
return
|
||||
}
|
||||
// Создаём новый Package с уникальным именем.
|
||||
// Это единственный способ сбросить кэш executor: он кэширует по functionUid и
|
||||
// не замечает изменений в существующем Package.
|
||||
envName, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
|
||||
createdAt := func() time.Time {
|
||||
ann := fn.GetAnnotations()
|
||||
if ann != nil {
|
||||
if v := strings.TrimSpace(ann[functionCreatedAtAnnotation]); v != "" {
|
||||
if ts, err := parseRFC3339(v); err == nil {
|
||||
return ts.UTC()
|
||||
}
|
||||
}
|
||||
}
|
||||
if ts := fn.GetCreationTimestamp(); !ts.IsZero() {
|
||||
return ts.UTC()
|
||||
}
|
||||
return time.Time{}
|
||||
}()
|
||||
now := time.Now().UTC()
|
||||
newPkgName := name + "-pkg-" + strconv.FormatInt(time.Now().UnixMilli(), 36)
|
||||
newPkg := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "fission.io/v1",
|
||||
"kind": "Package",
|
||||
"metadata": map[string]any{"name": newPkgName, "namespace": ns},
|
||||
"spec": map[string]any{
|
||||
"deployment": map[string]any{"type": "literal", "literal": base64.StdEncoding.EncodeToString(deployBytes)},
|
||||
"environment": map[string]any{"name": envName, "namespace": ns},
|
||||
"source": map[string]any{},
|
||||
},
|
||||
}}
|
||||
|
||||
updatedPkg, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Update(ctx, pkg, metav1.UpdateOptions{})
|
||||
createdPkg, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Create(ctx, newPkg, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update package %q: %v", pkgName, err))
|
||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create new package: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Обновляем Function на новый Package
|
||||
if err := unstructured.SetNestedField(fn.Object, normalizeFunctionTimeout(req.Timeout), "spec", "functionTimeout"); err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set function timeout: %v", err))
|
||||
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
|
||||
return
|
||||
}
|
||||
|
||||
// Синхронизируем resourceVersion в Function.spec.package.packageref
|
||||
// Это триггерит executor перезагрузить код в pool pod
|
||||
if err := unstructured.SetNestedField(fn.Object, updatedPkg.GetResourceVersion(), "spec", "package", "packageref", "resourceversion"); err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set function package resourceversion: %v", err))
|
||||
ensureFunctionTimestamps(fn, now)
|
||||
if createdAt.IsZero() {
|
||||
createdAt = now
|
||||
}
|
||||
fnAnnotations := fn.GetAnnotations()
|
||||
if fnAnnotations == nil {
|
||||
fnAnnotations = map[string]string{}
|
||||
}
|
||||
fnAnnotations[functionCreatedAtAnnotation] = createdAt.UTC().Format(time.RFC3339)
|
||||
fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339)
|
||||
fn.SetAnnotations(fnAnnotations)
|
||||
if err := unstructured.SetNestedField(fn.Object, map[string]any{
|
||||
"name": newPkgName,
|
||||
"namespace": ns,
|
||||
"resourceversion": createdPkg.GetResourceVersion(),
|
||||
}, "spec", "package", "packageref"); err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set function packageref: %v", err))
|
||||
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
|
||||
return
|
||||
}
|
||||
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Update(ctx, fn, metav1.UpdateOptions{}); err != nil {
|
||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update function %q package ref: %v", name, err))
|
||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update function %q: %v", name, err))
|
||||
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
|
||||
return
|
||||
}
|
||||
|
||||
// Удаляем старый Package (best effort)
|
||||
if oldPkgName != "" && oldPkgName != newPkgName {
|
||||
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, oldPkgName, metav1.DeleteOptions{})
|
||||
}
|
||||
|
||||
writeAnyJSON(w, http.StatusOK, map[string]any{
|
||||
"updated": true,
|
||||
"package": pkgName,
|
||||
"package_resourceversion": updatedPkg.GetResourceVersion(),
|
||||
"updated": true,
|
||||
"package": newPkgName,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
@@ -90,6 +92,9 @@ func decodeArchiveBytesToSource(decoded []byte) (string, error) {
|
||||
if len(decoded) == 0 {
|
||||
return "", io.ErrUnexpectedEOF
|
||||
}
|
||||
if src, ok := decodeNodeJSWrapperSource(decoded); ok {
|
||||
return src, nil
|
||||
}
|
||||
// Если байты — валидный UTF-8, возвращаем напрямую (python, ruby, php)
|
||||
if utf8.Valid(decoded) {
|
||||
return string(decoded), nil
|
||||
@@ -103,6 +108,24 @@ func decodeArchiveBytesToSource(decoded []byte) (string, error) {
|
||||
return "", io.ErrUnexpectedEOF
|
||||
}
|
||||
|
||||
var nodeJSWrapperPattern = regexp.MustCompile(`(?s)^const __mod = \{ exports: \{\} \};\s*\(new Function\('module', 'exports', (.+?)\)\)\(__mod, __mod\.exports\);\s*const _fn = __mod\.exports;`)
|
||||
|
||||
func decodeNodeJSWrapperSource(decoded []byte) (string, bool) {
|
||||
text := string(decoded)
|
||||
matches := nodeJSWrapperPattern.FindStringSubmatch(text)
|
||||
if len(matches) != 2 {
|
||||
return "", false
|
||||
}
|
||||
var code string
|
||||
if err := json.Unmarshal([]byte(matches[1]), &code); err != nil {
|
||||
return "", false
|
||||
}
|
||||
if strings.TrimSpace(code) == "" {
|
||||
return "", false
|
||||
}
|
||||
return code, true
|
||||
}
|
||||
|
||||
// decodeZipSource извлекает исходный UTF-8 файл из zip архива.
|
||||
// Предпочитает хорошо известные имена файлов (main.py, handler.go и т.д.).
|
||||
func decodeZipSource(zipBytes []byte) (string, error) {
|
||||
@@ -118,6 +141,9 @@ func decodeZipSource(zipBytes []byte) (string, error) {
|
||||
if strings.EqualFold(file.Name, name) {
|
||||
content, readErr := readZipFile(file)
|
||||
if readErr == nil && utf8.Valid(content) {
|
||||
if src, ok := decodeNodeJSWrapperSource(content); ok {
|
||||
return src, nil
|
||||
}
|
||||
return string(content), nil
|
||||
}
|
||||
}
|
||||
@@ -136,6 +162,9 @@ func decodeZipSource(zipBytes []byte) (string, error) {
|
||||
for _, file := range files {
|
||||
content, readErr := readZipFile(file)
|
||||
if readErr == nil && utf8.Valid(content) {
|
||||
if src, ok := decodeNodeJSWrapperSource(content); ok {
|
||||
return src, nil
|
||||
}
|
||||
return string(content), nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fission-console/internal/runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecodeNodeJSWrapperSource(t *testing.T) {
|
||||
wrapper := []byte("const __mod = { exports: {} };\n(new Function('module', 'exports', \"module.exports = async function () { return { status: 200, body: \\\"ok\\\" }; }\"))(__mod, __mod.exports);\nconst _fn = __mod.exports;")
|
||||
|
||||
src, ok := decodeNodeJSWrapperSource(wrapper)
|
||||
if !ok {
|
||||
t.Fatalf("decodeNodeJSWrapperSource() = false, want true")
|
||||
}
|
||||
want := "module.exports = async function () { return { status: 200, body: \"ok\" }; }"
|
||||
if src != want {
|
||||
t.Fatalf("decoded source = %q, want %q", src, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeArchiveBytesToSourcePrefersNodeJSWrapperExtraction(t *testing.T) {
|
||||
wrapper := []byte("const __mod = { exports: {} };\n(new Function('module', 'exports', \"module.exports = async function () { return { status: 200, body: \\\"ok\\\" }; }\"))(__mod, __mod.exports);\nconst _fn = __mod.exports;\n\n\tasync function __invoke(ctx) {\n const fn = typeof _fn === 'function' ? _fn : (_fn.default || _fn.handler || _fn.main);\n if (!fn) throw new Error('no exported function found in user code');\n const result = await fn(ctx);\n if (!result) return { status: 200, body: '' };\n if (typeof result.status !== 'undefined') return result;\n return { status: 200, ...result };\n}\n\n\texport { __invoke as main, __invoke as handler };\n\texport default __invoke;\n")
|
||||
|
||||
src, err := decodeArchiveBytesToSource(wrapper)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeArchiveBytesToSource() error = %v", err)
|
||||
}
|
||||
want := "module.exports = async function () { return { status: 200, body: \"ok\" }; }"
|
||||
if src != want {
|
||||
t.Fatalf("decoded source = %q, want %q", src, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeArchiveBytesToSourcePrefersNodeJSWrapperExtractionWithNewlines(t *testing.T) {
|
||||
wrapper := []byte("const __mod = { exports: {} };\n(new Function('module', 'exports', \"module.exports = async function (context) {\\n const value = await Promise.resolve(context?.value || 'ok');\\n return { status: 200, body: value };\\n}\"))(__mod, __mod.exports);\nconst _fn = __mod.exports;\n\n\tasync function __invoke(ctx) {\n const fn = typeof _fn === 'function' ? _fn : (_fn.default || _fn.handler || _fn.main);\n if (!fn) throw new Error('no exported function found in user code');\n const result = await fn(ctx);\n if (!result) return { status: 200, body: '' };\n if (typeof result.status !== 'undefined') return result;\n return { status: 200, ...result };\n}\n\n\texport { __invoke as main, __invoke as handler };\n\texport default __invoke;\n")
|
||||
|
||||
src, err := decodeArchiveBytesToSource(wrapper)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeArchiveBytesToSource() error = %v", err)
|
||||
}
|
||||
want := "module.exports = async function (context) {\n const value = await Promise.resolve(context?.value || 'ok');\n return { status: 200, body: value };\n}"
|
||||
if src != want {
|
||||
t.Fatalf("decoded source = %q, want %q", src, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeArchiveBytesToSourceKeepsPythonMultilineSource(t *testing.T) {
|
||||
src := "def main(context):\n value = context.get('value', 'ok')\n return {'status': 200, 'body': value}\n"
|
||||
got, err := decodeArchiveBytesToSource([]byte(src))
|
||||
if err != nil {
|
||||
t.Fatalf("decodeArchiveBytesToSource() error = %v", err)
|
||||
}
|
||||
if got != src {
|
||||
t.Fatalf("decoded source = %q, want %q", got, src)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeArchiveBytesToSourceUnwrapsNodeJSZipPayload(t *testing.T) {
|
||||
zipBytes, err := runtime.BuildJSDeployZip(`module.exports = async function (context) {
|
||||
return { status: 200, body: context?.value || "ok" };
|
||||
}`)
|
||||
if err != nil {
|
||||
t.Fatalf("runtime.BuildJSDeployZip() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := decodeArchiveBytesToSource(zipBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeArchiveBytesToSource() error = %v", err)
|
||||
}
|
||||
want := `module.exports = async function (context) {
|
||||
return { status: 200, body: context?.value || "ok" };
|
||||
}`
|
||||
if got != want {
|
||||
t.Fatalf("decoded source = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeArchiveBytesToSourceKeepsRubyMultilineSource(t *testing.T) {
|
||||
src := "def handler(context)\n value = context[:value] || 'ok'\n { status: 200, body: value }\nend\n"
|
||||
got, err := decodeArchiveBytesToSource([]byte(src))
|
||||
if err != nil {
|
||||
t.Fatalf("decodeArchiveBytesToSource() error = %v", err)
|
||||
}
|
||||
if got != src {
|
||||
t.Fatalf("decoded source = %q, want %q", got, src)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeArchiveBytesToSourceKeepsPHPMultilineSource(t *testing.T) {
|
||||
src := "<?php\nfunction handler(array $context): array {\n $value = $context['value'] ?? 'ok';\n return ['status' => 200, 'body' => $value];\n}\n"
|
||||
got, err := decodeArchiveBytesToSource([]byte(src))
|
||||
if err != nil {
|
||||
t.Fatalf("decodeArchiveBytesToSource() error = %v", err)
|
||||
}
|
||||
if got != src {
|
||||
t.Fatalf("decoded source = %q, want %q", got, src)
|
||||
}
|
||||
}
|
||||
@@ -9,24 +9,10 @@ import (
|
||||
|
||||
// BuildJSDeployZip упаковывает Node.js код в zip для Fission deployment package.
|
||||
//
|
||||
// Почему zip, а не просто literal-код:
|
||||
// Fission node-env v3 поддерживает ESM модули. Чтобы Node.js трактовал файл
|
||||
// как ESM, нужен package.json с {"type":"module"} рядом с main.js.
|
||||
// Без него `import` синтаксис вызовет "require is not defined" или SyntaxError.
|
||||
//
|
||||
// Почему new Function:
|
||||
// Пользовательский код пишет CJS-стиль (module.exports = ...) но мы хотим ESM wrapper.
|
||||
// new Function() изолирует module/exports от глобального ESM контекста и позволяет
|
||||
// исполнять CJS-код без изменений.
|
||||
//
|
||||
// Результат: zip с двумя файлами:
|
||||
// - package.json: {"type":"module"}
|
||||
// - main.js: ESM wrapper + инлайн пользовательский код через new Function
|
||||
//
|
||||
// Совместимость entrypoint:
|
||||
// UI исторически отправлял entrypoint="handler", а backend по умолчанию использует
|
||||
// entrypoint="main". Чтобы specialization не ломался из-за несовпадения,
|
||||
// wrapper экспортирует обе точки входа: named exports main и handler, а также default.
|
||||
// Генерируем CommonJS wrapper: так Fission/node executor не упирается в ESM export
|
||||
// синтаксис и может исполнять результат без package.json и type=module.
|
||||
// Пользовательский код остаётся CJS-стилем через new Function(module, exports).
|
||||
// Wrapper экспортирует main, handler и default через module.exports.
|
||||
func BuildJSDeployZip(code string) ([]byte, error) {
|
||||
// Сериализуем пользовательский код в JSON строку чтобы безопасно инлайнить
|
||||
// в JavaScript-литерал — экранирует кавычки, переводы строк, спецсимволы.
|
||||
@@ -35,10 +21,10 @@ func BuildJSDeployZip(code string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("marshal user code: %w", err)
|
||||
}
|
||||
|
||||
// main.js — ESM wrapper:
|
||||
// main.js — CommonJS wrapper:
|
||||
// 1. new Function создаёт функцию в пустом модульном контексте (нет import/export)
|
||||
// 2. Передаём ей module и exports как параметры → пользовательский CJS код работает
|
||||
// 3. Экспортируем default async function для Fission node-env entrypoint
|
||||
// 3. Экспортируем main/handler/default через module.exports
|
||||
wrapper := fmt.Sprintf(`const __mod = { exports: {} };
|
||||
(new Function('module', 'exports', %s))(__mod, __mod.exports);
|
||||
const _fn = __mod.exports;
|
||||
@@ -52,22 +38,15 @@ const _fn = __mod.exports;
|
||||
return { status: 200, ...result };
|
||||
}
|
||||
|
||||
export { __invoke as main, __invoke as handler };
|
||||
export default __invoke;
|
||||
module.exports = __invoke;
|
||||
module.exports.main = __invoke;
|
||||
module.exports.handler = __invoke;
|
||||
module.exports.default = __invoke;
|
||||
`, string(codeJSON))
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
// package.json: включает ESM режим для всего архива
|
||||
pkgfw, err := zw.Create("package.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := pkgfw.Write([]byte(`{"type":"module"}`)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fw, err := zw.Create("main.js")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
+31
-3
@@ -451,6 +451,8 @@
|
||||
<th>Имя</th>
|
||||
<th>Окружение</th>
|
||||
<th>Пакет</th>
|
||||
<th>Создана</th>
|
||||
<th>Изменена</th>
|
||||
<th>Маршрут</th>
|
||||
<th>Методы</th>
|
||||
<th class="nowrap">Действия</th>
|
||||
@@ -808,6 +810,25 @@
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function formatTimestamp(ts) {
|
||||
if (!ts) return '—';
|
||||
var d = new Date(ts);
|
||||
if (isNaN(d.getTime())) return String(ts);
|
||||
return d.toLocaleString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function timestampCell(ts) {
|
||||
var text = formatTimestamp(ts);
|
||||
return '<span class="mono" title="' + h(ts || '') + '">' + h(text) + '</span>';
|
||||
}
|
||||
|
||||
const LANG_TEMPLATES = {
|
||||
python: {
|
||||
entrypoint: 'main.main',
|
||||
@@ -936,6 +957,7 @@
|
||||
});
|
||||
closeEdit();
|
||||
progress.stop('\u041a\u043e\u0434 \u043e\u0431\u043d\u043e\u0432\u043b\u0451\u043d: ' + name, 'ok');
|
||||
await reloadAll();
|
||||
} catch (e) {
|
||||
progress.stop('\u041e\u0448\u0438\u0431\u043a\u0430 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f: ' + e.message, 'err');
|
||||
} finally {
|
||||
@@ -1043,6 +1065,8 @@
|
||||
|
||||
const rows = (fns || []).map(function (f) {
|
||||
const spec = f.spec || {};
|
||||
const meta = f.metadata || {};
|
||||
const ann = meta.annotations || {};
|
||||
const env = (spec.environment && spec.environment.name) || '-';
|
||||
const pkg = (spec.package && spec.package.packageref && spec.package.packageref.name) || '-';
|
||||
const name = (f.metadata && f.metadata.name) || '-';
|
||||
@@ -1050,6 +1074,8 @@
|
||||
const route = (trig.spec && trig.spec.relativeurl) || '-';
|
||||
const methods = (trig.spec && trig.spec.methods) || [];
|
||||
const chips = methods.map(function (m) { return '<span class="chip">' + h(m) + '</span>'; }).join('');
|
||||
const createdAt = ann['fission-console/created-at'] || meta.creationTimestamp || '-';
|
||||
const updatedAt = ann['fission-console/updated-at'] || createdAt;
|
||||
var isGo = /go[-_]env/.test(env);
|
||||
var isTf = /^tf-/.test(name);
|
||||
var tfBadge = (isGo || isTf) ? '<span class="chip" style="background:#555;color:#ffa" title="Управляется Terraform. Изменения могут быть перезаписаны при terraform apply.">TF</span> ' : '';
|
||||
@@ -1061,18 +1087,20 @@
|
||||
'<td class="mono">' + h(name) + '</td>' +
|
||||
'<td>' + h(env) + '</td>' +
|
||||
'<td class="mono">' + h(pkg) + '</td>' +
|
||||
'<td>' + timestampCell(createdAt) + '</td>' +
|
||||
'<td>' + timestampCell(updatedAt) + '</td>' +
|
||||
'<td class="mono">' + h(route) + '</td>' +
|
||||
'<td>' + chips + '</td>' +
|
||||
'<td class="nowrap">' + actions + '</td>' +
|
||||
'</tr>';
|
||||
}).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="8">\u041d\u0435\u0442 \u0444\u0443\u043d\u043a\u0446\u0438\u0439</td></tr>';
|
||||
if (!rows) {
|
||||
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="6">\u041d\u0435\u0442 \u0444\u0443\u043d\u043a\u0446\u0438\u0439</td></tr>';
|
||||
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="8">\u041d\u0435\u0442 \u0444\u0443\u043d\u043a\u0446\u0438\u0439</td></tr>';
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="6">Load error: ' + e.message + '</td></tr>';
|
||||
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="8">Load error: ' + e.message + '</td></tr>';
|
||||
showStatus('\u041e\u0448\u0438\u0431\u043a\u0430 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0438: ' + e.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user