From 01df1498d614eb7c0ade70826cb54c64c5ff18da Mon Sep 17 00:00:00 2001 From: Naeel Date: Mon, 20 Apr 2026 11:38:45 +0300 Subject: [PATCH] feat: big-suite terraform example + provider nodejs zip fix - examples/big-suite: E-Commerce 10 functions, 6 layers, real depends_on - provider: nodejs packages now wrapped in ESM zip (buildJSDeployZip) - provider: loadPackageLiteral reads raw file, nodejs zip via source_dir - all 10 functions verified working: python/node/ruby/go runtimes --- console/main.go | 75 +++++++++++++- examples/big-suite/code/go-order/go.mod | 3 + examples/big-suite/code/go-order/handler.go | 98 +++++++++++++++++++ examples/big-suite/code/go-report/go.mod | 3 + examples/big-suite/code/go-report/handler.go | 68 +++++++++++++ examples/big-suite/code/node-health/main.js | 20 ++++ examples/big-suite/code/node-payment/main.js | 70 +++++++++++++ .../big-suite/code/node-ratelimiter/main.js | 62 ++++++++++++ examples/big-suite/code/python-auth/main.py | 44 +++++++++ examples/big-suite/code/python-cart/main.py | 70 +++++++++++++ examples/big-suite/code/python-user/main.py | 48 +++++++++ examples/big-suite/code/ruby-notifier/main.rb | 40 ++++++++ examples/big-suite/code/ruby-product/main.rb | 29 ++++++ examples/big-suite/environments.tf | 33 +++++++ examples/big-suite/functions_async.tf | 59 +++++++++++ examples/big-suite/functions_checkout.tf | 61 ++++++++++++ examples/big-suite/functions_core.tf | 49 ++++++++++ examples/big-suite/functions_infra.tf | 24 +++++ examples/big-suite/functions_services.tf | 85 ++++++++++++++++ examples/big-suite/main.tf | 13 +++ examples/big-suite/outputs.tf | 32 ++++++ .../internal/resources/package_resource.go | 52 ++++++++++ 22 files changed, 1033 insertions(+), 5 deletions(-) create mode 100644 examples/big-suite/code/go-order/go.mod create mode 100644 examples/big-suite/code/go-order/handler.go create mode 100644 examples/big-suite/code/go-report/go.mod create mode 100644 examples/big-suite/code/go-report/handler.go create mode 100644 examples/big-suite/code/node-health/main.js create mode 100644 examples/big-suite/code/node-payment/main.js create mode 100644 examples/big-suite/code/node-ratelimiter/main.js create mode 100644 examples/big-suite/code/python-auth/main.py create mode 100644 examples/big-suite/code/python-cart/main.py create mode 100644 examples/big-suite/code/python-user/main.py create mode 100644 examples/big-suite/code/ruby-notifier/main.rb create mode 100644 examples/big-suite/code/ruby-product/main.rb create mode 100644 examples/big-suite/environments.tf create mode 100644 examples/big-suite/functions_async.tf create mode 100644 examples/big-suite/functions_checkout.tf create mode 100644 examples/big-suite/functions_core.tf create mode 100644 examples/big-suite/functions_infra.tf create mode 100644 examples/big-suite/functions_services.tf create mode 100644 examples/big-suite/main.tf create mode 100644 examples/big-suite/outputs.tf diff --git a/console/main.go b/console/main.go index 28a0a76..c3e7218 100644 --- a/console/main.go +++ b/console/main.go @@ -85,6 +85,7 @@ type createFunctionRequest struct { type langEnvDef struct { Image string BuilderImage string + Version int // 0 defaults to 3 } var langEnvMap = map[string]langEnvDef{ @@ -93,7 +94,7 @@ var langEnvMap = map[string]langEnvDef{ "go": {Image: "ghcr.io/fission/go-env", BuilderImage: "ghcr.io/fission/go-builder"}, "php": {Image: "ghcr.io/fission/php-env"}, "ruby": {Image: "ghcr.io/fission/ruby-env"}, - "perl": {Image: "ghcr.io/fission/perl-env"}, + "perl": {Image: "ghcr.io/fission/perl-env", Version: 1}, } type updateCodeRequest struct { @@ -320,7 +321,12 @@ func (s *server) handleCreateFunction(w http.ResponseWriter, r *http.Request) { defer envCancel() envName, err := s.ensureEnvironment(envCtx, ns, req.Language) if err != nil { - writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure environment: %v", err)) + // unsupported language — это клиентская ошибка → 400 + if strings.Contains(err.Error(), "unsupported language") { + writeJSONError(w, http.StatusBadRequest, err.Error()) + } else { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure environment: %v", err)) + } return } req.Environment = envName @@ -512,8 +518,12 @@ func (s *server) handleCreateFunction(w http.ResponseWriter, r *http.Request) { } func (s *server) buildLangEnvironment(name string, ns string, def langEnvDef) *unstructured.Unstructured { + envVersion := int64(3) + if def.Version != 0 { + envVersion = int64(def.Version) + } spec := map[string]any{ - "version": int64(3), + "version": envVersion, "runtime": map[string]any{ "image": def.Image, }, @@ -746,7 +756,16 @@ func buildJSDeployZip(code string) ([]byte, error) { var buf bytes.Buffer zw := zip.NewWriter(&buf) - // main.js — единственный файл: ESM wrapper + код пользователя инлайн через new Function + // package.json: объявляем ESM тип чтобы Node.js трактовал .js как 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 + } + + // main.js — ESM wrapper + код пользователя инлайн через new Function // new Function безопасно изолирует module/exports от глобального контекста codeJSON, err := json.Marshal(code) if err != nil { @@ -780,10 +799,34 @@ export default async function(ctx) { return buf.Bytes(), nil } +// buildScriptZip wraps code into a zip file with the given filename. +// Used for PHP, Ruby, Perl where the environment requires a named source file. +func buildScriptZip(code, filename string) ([]byte, error) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + fw, err := zw.Create(filename) + if err != nil { + return nil, err + } + if _, err := fw.Write([]byte(code)); err != nil { + return nil, err + } + if err := zw.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + func defaultEntrypoint(lang string) string { switch lang { - case "nodejs", "php", "ruby", "perl": + case "nodejs": return "main" + case "php": + return "main.php::handler" + case "ruby": + return "handler" + case "perl": + return "handler" case "go": return "Handler" default: @@ -1255,6 +1298,28 @@ func (s *server) ensureUserNamespace(ctx context.Context, ns string) error { return fmt.Errorf("create namespace %s: %w", ns, err) } + // 1a. ServiceAccounts для Fission в user namespace. + // + // Fission pool pods (fetcher sidecar) запускаются в user namespace и требуют + // `serviceAccountName: fission-fetcher` в том же namespace. Executor (с SERVICEACCOUNT_CHECK_ENABLED=false) + // не создаёт эти SA автоматически → pool pods падают с "serviceaccount not found". + // Создаём явно при каждом вызове (idempotent через IsAlreadyExists). + saGVR := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "serviceaccounts"} + for _, saName := range []string{"fission-fetcher", "fission-builder"} { + saObj := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": map[string]any{ + "name": saName, + "namespace": ns, + }, + }} + _, saErr := s.dyn.Resource(saGVR).Namespace(ns).Create(ctx, saObj, metav1.CreateOptions{}) + if saErr != nil && !apierrors.IsAlreadyExists(saErr) { + log.Printf("ensureUserNamespace: create SA %s/%s: %v", ns, saName, saErr) + } + } + // 1b. RoleBindings для Fission SA в user namespace. // // Проблема: Fission компоненты (executor, router, buildermgr и др.) работают в namespace diff --git a/examples/big-suite/code/go-order/go.mod b/examples/big-suite/code/go-order/go.mod new file mode 100644 index 0000000..cea403b --- /dev/null +++ b/examples/big-suite/code/go-order/go.mod @@ -0,0 +1,3 @@ +module github.com/user/fn + +go 1.23 diff --git a/examples/big-suite/code/go-order/handler.go b/examples/big-suite/code/go-order/handler.go new file mode 100644 index 0000000..065ff84 --- /dev/null +++ b/examples/big-suite/code/go-order/handler.go @@ -0,0 +1,98 @@ +package main + +import ( + "encoding/json" + "fmt" + "math/rand" + "net/http" + "time" +) + +type OrderItem struct { + ProductID string `json:"product_id"` + Name string `json:"name"` + Price float64 `json:"price"` + Qty int `json:"qty"` +} + +type OrderRequest struct { + UserID string `json:"user_id"` + Items []OrderItem `json:"items"` + PayToken string `json:"pay_token"` + Address string `json:"address"` +} + +type OrderResponse struct { + Status string `json:"status"` + OrderID string `json:"order_id"` + UserID string `json:"user_id"` + Items []OrderItem `json:"items"` + Total float64 `json:"total"` + CreatedAt string `json:"created_at"` + EstDays int `json:"estimated_delivery_days"` +} + +func generateOrderID() string { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + r := rand.New(rand.NewSource(time.Now().UnixNano())) + b := make([]byte, 10) + for i := range b { + b[i] = chars[r.Intn(len(chars))] + } + return "ORD-" + string(b) +} + +func calcTotal(items []OrderItem) float64 { + total := 0.0 + for _, item := range items { + total += item.Price * float64(item.Qty) + } + // Округление до 2 знаков + return float64(int(total*100)) / 100 +} + +func Handler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + fmt.Fprintln(w, `{"error":"method not allowed"}`) + return + } + + var req OrderRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, `{"error":"invalid JSON: %s"}`, err.Error()) + return + } + + if req.UserID == "" { + w.WriteHeader(http.StatusUnprocessableEntity) + fmt.Fprintln(w, `{"error":"user_id required"}`) + return + } + if len(req.Items) == 0 { + w.WriteHeader(http.StatusUnprocessableEntity) + fmt.Fprintln(w, `{"error":"items cannot be empty"}`) + return + } + if req.PayToken == "" { + w.WriteHeader(http.StatusUnprocessableEntity) + fmt.Fprintln(w, `{"error":"pay_token required"}`) + return + } + + resp := OrderResponse{ + Status: "created", + OrderID: generateOrderID(), + UserID: req.UserID, + Items: req.Items, + Total: calcTotal(req.Items), + CreatedAt: time.Now().UTC().Format(time.RFC3339), + EstDays: 3 + rand.Intn(5), + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(resp) +} diff --git a/examples/big-suite/code/go-report/go.mod b/examples/big-suite/code/go-report/go.mod new file mode 100644 index 0000000..cea403b --- /dev/null +++ b/examples/big-suite/code/go-report/go.mod @@ -0,0 +1,3 @@ +module github.com/user/fn + +go 1.23 diff --git a/examples/big-suite/code/go-report/handler.go b/examples/big-suite/code/go-report/handler.go new file mode 100644 index 0000000..acb1aef --- /dev/null +++ b/examples/big-suite/code/go-report/handler.go @@ -0,0 +1,68 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "time" +) + +// Симулирует агрегацию данных за день (в реальности — запрос к БД) + +type DailyStat struct { + Date string `json:"date"` + Orders int `json:"orders"` + Revenue float64 `json:"revenue"` + AvgOrder float64 `json:"avg_order_value"` + NewUsers int `json:"new_users"` + TopCategory string `json:"top_category"` +} + +func generateDailyStats(days int) []DailyStat { + stats := make([]DailyStat, days) + base := time.Now().UTC() + revenues := []float64{4821.50, 3920.00, 6100.75, 5200.10, 7330.40, 4100.00, 8950.30} + orders := []int{38, 31, 49, 42, 58, 33, 72} + categories := []string{"electronics", "furniture", "electronics", "stationery", "electronics", "furniture", "electronics"} + + for i := 0; i < days; i++ { + day := base.AddDate(0, 0, -(days - 1 - i)) + rev := revenues[i%len(revenues)] + ord := orders[i%len(orders)] + stats[i] = DailyStat{ + Date: day.Format("2006-01-02"), + Orders: ord, + Revenue: rev, + AvgOrder: float64(int(rev/float64(ord)*100)) / 100, + NewUsers: 5 + i*2, + TopCategory: categories[i%len(categories)], + } + } + return stats +} + +func Handler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + days := 7 + stats := generateDailyStats(days) + + totalRevenue := 0.0 + totalOrders := 0 + for _, s := range stats { + totalRevenue += s.Revenue + totalOrders += s.Orders + } + + report := map[string]interface{}{ + "status": "ok", + "generated_at": time.Now().UTC().Format(time.RFC3339), + "period_days": days, + "total_revenue": fmt.Sprintf("%.2f", totalRevenue), + "total_orders": totalOrders, + "avg_daily_rev": fmt.Sprintf("%.2f", totalRevenue/float64(days)), + "daily_breakdown": stats, + } + + json.NewEncoder(w).Encode(report) +} diff --git a/examples/big-suite/code/node-health/main.js b/examples/big-suite/code/node-health/main.js new file mode 100644 index 0000000..a9fd532 --- /dev/null +++ b/examples/big-suite/code/node-health/main.js @@ -0,0 +1,20 @@ +exports.handler = async (ctx) => { + const start = Date.now(); + + return { + body: JSON.stringify({ + status: "ok", + service: "ecom-platform", + version: "1.0.0", + uptime_check: "pass", + checks: { + runtime: "ok", + memory_mb: process.memoryUsage ? Math.round(process.memoryUsage().heapUsed / 1024 / 1024) : null, + node_version: process.version || "unknown", + }, + timestamp: new Date().toISOString(), + response_ms: Date.now() - start, + }), + headers: { "Content-Type": "application/json" }, + }; +}; diff --git a/examples/big-suite/code/node-payment/main.js b/examples/big-suite/code/node-payment/main.js new file mode 100644 index 0000000..bef2715 --- /dev/null +++ b/examples/big-suite/code/node-payment/main.js @@ -0,0 +1,70 @@ +// Валидирует платёжные данные перед созданием заказа + +exports.handler = async (ctx) => { + const req = ctx.request; + + if (!req.body) { + return { + status: 400, + body: JSON.stringify({ error: "request body required" }), + headers: { "Content-Type": "application/json" }, + }; + } + + let data; + try { + data = typeof req.body === 'string' ? JSON.parse(req.body) : req.body; + } catch { + return { + status: 400, + body: JSON.stringify({ error: "invalid JSON" }), + headers: { "Content-Type": "application/json" }, + }; + } + + const errors = []; + + // card_number: 16 цифр + const card = String(data.card_number || "").replace(/\s/g, ""); + if (!/^\d{16}$/.test(card)) errors.push("card_number must be 16 digits"); + + // cvv: 3 цифры + const cvv = String(data.cvv || ""); + if (!/^\d{3}$/.test(cvv)) errors.push("cvv must be 3 digits"); + + // expiry: MM/YY + const expiry = String(data.expiry || ""); + if (!/^(0[1-9]|1[0-2])\/\d{2}$/.test(expiry)) errors.push("expiry must be MM/YY"); + else { + const [mm, yy] = expiry.split("/").map(Number); + const now = new Date(); + const exp = new Date(2000 + yy, mm - 1); + if (exp < now) errors.push("card is expired"); + } + + // amount + const amount = Number(data.amount); + if (!amount || amount <= 0) errors.push("amount must be positive number"); + if (amount > 10000) errors.push("amount exceeds limit 10000"); + + if (errors.length > 0) { + return { + status: 422, + body: JSON.stringify({ valid: false, errors }), + headers: { "Content-Type": "application/json" }, + }; + } + + // Маскируем карту в ответе + const maskedCard = `****-****-****-${card.slice(-4)}`; + return { + body: JSON.stringify({ + valid: true, + masked_card: maskedCard, + amount, + currency: data.currency || "USD", + token: `pay_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + }), + headers: { "Content-Type": "application/json" }, + }; +}; diff --git a/examples/big-suite/code/node-ratelimiter/main.js b/examples/big-suite/code/node-ratelimiter/main.js new file mode 100644 index 0000000..c38f312 --- /dev/null +++ b/examples/big-suite/code/node-ratelimiter/main.js @@ -0,0 +1,62 @@ +// Rate-limiter: проверяет X-Client-ID, считает запросы в памяти пода +// В реальном проекте — Redis. Здесь: in-memory для демонстрации. + +const counters = {}; +const WINDOW_MS = 60_000; +const LIMIT = 100; + +exports.handler = async (ctx) => { + const req = ctx.request; + const clientId = (req.headers && req.headers["x-client-id"]) || "anonymous"; + const now = Date.now(); + + if (!counters[clientId]) { + counters[clientId] = { count: 0, windowStart: now }; + } + + const c = counters[clientId]; + if (now - c.windowStart > WINDOW_MS) { + c.count = 0; + c.windowStart = now; + } + + c.count++; + + const allowed = c.count <= LIMIT; + const remaining = Math.max(0, LIMIT - c.count); + const resetAt = new Date(c.windowStart + WINDOW_MS).toISOString(); + + if (!allowed) { + return { + status: 429, + body: JSON.stringify({ + error: "rate limit exceeded", + client_id: clientId, + limit: LIMIT, + reset_at: resetAt, + }), + headers: { + "Content-Type": "application/json", + "X-RateLimit-Limit": String(LIMIT), + "X-RateLimit-Remaining": "0", + "X-RateLimit-Reset": resetAt, + }, + }; + } + + return { + body: JSON.stringify({ + allowed: true, + client_id: clientId, + requests_in_window: c.count, + remaining, + limit: LIMIT, + reset_at: resetAt, + }), + headers: { + "Content-Type": "application/json", + "X-RateLimit-Limit": String(LIMIT), + "X-RateLimit-Remaining": String(remaining), + }, + }; +}; diff --git a/examples/big-suite/code/python-auth/main.py b/examples/big-suite/code/python-auth/main.py new file mode 100644 index 0000000..0a58d44 --- /dev/null +++ b/examples/big-suite/code/python-auth/main.py @@ -0,0 +1,44 @@ +import hashlib +import hmac +import json +import time +from flask import request + +_SECRET = b"ecom-secret-2026" + +_USERS = { + "tok_alice_001": {"sub": "user-1", "name": "Alice", "roles": ["buyer"]}, + "tok_bob_002": {"sub": "user-2", "name": "Bob", "roles": ["buyer", "seller"]}, + "tok_admin_999": {"sub": "user-0", "name": "Admin", "roles": ["admin"]}, +} + + +def _verify_token(token: str) -> dict | None: + if token in _USERS: + return _USERS[token] + return None + + +def main(): + token = request.headers.get("x-bearer-token", "").strip() + if not token: + auth_header = request.headers.get("authorization", "") + token = auth_header.removeprefix("Bearer ").strip() + + if not token: + return {"status": 401, "body": json.dumps({"error": "missing token"}), + "headers": {"Content-Type": "application/json"}} + + user = _verify_token(token) + if not user: + return {"status": 403, "body": json.dumps({"error": "invalid token"}), + "headers": {"Content-Type": "application/json"}} + + payload = { + "authenticated": True, + "sub": user["sub"], + "name": user["name"], + "roles": user["roles"], + "checked_at": int(time.time()), + } + return json.dumps(payload) diff --git a/examples/big-suite/code/python-cart/main.py b/examples/big-suite/code/python-cart/main.py new file mode 100644 index 0000000..acec356 --- /dev/null +++ b/examples/big-suite/code/python-cart/main.py @@ -0,0 +1,70 @@ +import json +import time +from flask import request + +# In-memory корзина (живёт пока жив под poolmgr) +_CARTS: dict = {} + + +def _get_cart(user_id: str) -> dict: + if user_id not in _CARTS: + _CARTS[user_id] = {"user_id": user_id, "items": [], "created_at": int(time.time())} + return _CARTS[user_id] + + +def _cart_total(cart: dict) -> float: + return round(sum(i["price"] * i["qty"] for i in cart["items"]), 2) + + +def main(): + method = request.method.upper() + user_id = request.headers.get("x-user-id", "user-1") + body = {} + if request.data: + try: + body = json.loads(request.data) + except Exception: + pass + + cart = _get_cart(user_id) + + if method == "GET": + return json.dumps({ + "status": "ok", + "cart": cart, + "total": _cart_total(cart), + "item_count": len(cart["items"]), + }) + + if method == "POST": + # Добавить товар: {"product_id": "p-001", "name": "...", "price": 29.99, "qty": 2} + required = ["product_id", "name", "price", "qty"] + if not all(k in body for k in required): + return {"status": 400, + "body": json.dumps({"error": "missing fields", "required": required}), + "headers": {"Content-Type": "application/json"}} + + # Если товар уже в корзине — увеличиваем qty + for item in cart["items"]: + if item["product_id"] == body["product_id"]: + item["qty"] += int(body["qty"]) + return json.dumps({"status": "ok", "action": "updated", "cart": cart, + "total": _cart_total(cart)}) + + cart["items"].append({ + "product_id": body["product_id"], + "name": body["name"], + "price": float(body["price"]), + "qty": int(body["qty"]), + }) + return json.dumps({"status": "ok", "action": "added", "cart": cart, + "total": _cart_total(cart)}) + + if method == "DELETE": + product_id = body.get("product_id", "") + cart["items"] = [i for i in cart["items"] if i["product_id"] != product_id] + return json.dumps({"status": "ok", "action": "removed", "cart": cart, + "total": _cart_total(cart)}) + + return {"status": 405, "body": json.dumps({"error": "method not allowed"}), + "headers": {"Content-Type": "application/json"}} diff --git a/examples/big-suite/code/python-user/main.py b/examples/big-suite/code/python-user/main.py new file mode 100644 index 0000000..cf51c94 --- /dev/null +++ b/examples/big-suite/code/python-user/main.py @@ -0,0 +1,48 @@ +import json +from flask import request + +_DB = { + "user-1": {"id": "user-1", "name": "Alice", "email": "alice@example.com", "tier": "gold", "orders": 42}, + "user-2": {"id": "user-2", "name": "Bob", "email": "bob@example.com", "tier": "silver", "orders": 11}, + "user-0": {"id": "user-0", "name": "Admin", "email": "admin@example.com", "tier": "admin", "orders": 0}, +} + + +def main(): + # Path variables не передаются в Fission функцию — используем query param ?id= + user_id = request.args.get("id", "") + + if not user_id or user_id not in _DB: + return { + "status": 404, + "body": json.dumps({"error": "user not found", "id": user_id}), + "headers": {"Content-Type": "application/json"}, + } + + user = dict(_DB[user_id]) + user.pop("email", None) + return json.dumps({"status": "ok", "user": user}) + + # Достаём id из query string или пути + user_id = "" + if hasattr(context, "request"): + path = getattr(context.request, "url", "") or "" + # /ecom/users/user-1 → user-1 + parts = [p for p in path.split("/") if p] + if parts: + user_id = parts[-1] + qs = getattr(context.request, "query", {}) or {} + if not user_id and "id" in qs: + user_id = qs["id"] + + if not user_id or user_id not in _DB: + return { + "status": 404, + "body": json.dumps({"error": "user not found", "id": user_id}), + "headers": {"Content-Type": "application/json"}, + } + + user = dict(_DB[user_id]) + # Не возвращаем email в публичном ответе + user.pop("email", None) + return json.dumps({"status": "ok", "user": user}) diff --git a/examples/big-suite/code/ruby-notifier/main.rb b/examples/big-suite/code/ruby-notifier/main.rb new file mode 100644 index 0000000..41f3d96 --- /dev/null +++ b/examples/big-suite/code/ruby-notifier/main.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true +require "json" + +CHANNELS = %w[email sms push].freeze + +def handler + # В реальности: читаем body запроса, определяем канал и шлём уведомление + # Здесь: симуляция отправки по всем каналам + + order_id = "ORD-DEMO" + user_name = "Alice" + amount = 129.99 + + results = CHANNELS.map do |ch| + # Симуляция: email — всегда ок, sms — 95%, push — 90% + success = case ch + when "email" then true + when "sms" then rand(100) < 95 + when "push" then rand(100) < 90 + end + { + channel: ch, + status: success ? "sent" : "failed", + message: "Order #{order_id} confirmed — total $#{amount}", + recipient: "#{user_name.downcase}@notify", + } + end + + sent = results.count { |r| r[:status] == "sent" } + total = results.length + + { + status: "ok", + order_id: order_id, + sent: sent, + failed: total - sent, + channels: results, + sent_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"), + }.to_json +end diff --git a/examples/big-suite/code/ruby-product/main.rb b/examples/big-suite/code/ruby-product/main.rb new file mode 100644 index 0000000..b209429 --- /dev/null +++ b/examples/big-suite/code/ruby-product/main.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +require "json" + +PRODUCTS = [ + { id: "p-001", name: "Laptop Pro 15", price: 1299.99, category: "electronics", stock: 14 }, + { id: "p-002", name: "Wireless Mouse", price: 29.99, category: "electronics", stock: 203 }, + { id: "p-003", name: "Standing Desk", price: 549.00, category: "furniture", stock: 7 }, + { id: "p-004", name: "USB-C Hub 7-in-1", price: 49.99, category: "electronics", stock: 88 }, + { id: "p-005", name: "Ergonomic Chair", price: 399.00, category: "furniture", stock: 3 }, + { id: "p-006", name: "Monitor 4K 27\"", price: 699.00, category: "electronics", stock: 22 }, + { id: "p-007", name: "Notebook A5", price: 4.99, category: "stationery", stock: 500 }, + { id: "p-008", name: "Pen Set 12pc", price: 9.99, category: "stationery", stock: 320 }, +].freeze + +def handler + # Простая фильтрация по category (query string не доступен в v1 Perl-style, + # но для Ruby v3 env возвращаем весь каталог) + total = PRODUCTS.length + in_stock = PRODUCTS.count { |p| p[:stock] > 0 } + by_cat = PRODUCTS.group_by { |p| p[:category] }.transform_values(&:length) + + { + status: "ok", + total: total, + in_stock: in_stock, + by_category: by_cat, + products: PRODUCTS, + }.to_json +end diff --git a/examples/big-suite/environments.tf b/examples/big-suite/environments.tf new file mode 100644 index 0000000..4474903 --- /dev/null +++ b/examples/big-suite/environments.tf @@ -0,0 +1,33 @@ +# ────────────────────────────────────────────────────────────────────────────── +# Environments — E-Commerce Platform API +# ────────────────────────────────────────────────────────────────────────────── + +resource "fission_environment" "python" { + name = "ecom-python-env" + image = "ghcr.io/fission/python-env" + version = 3 + poolsize = 3 +} + +resource "fission_environment" "node" { + name = "ecom-node-env" + image = "ghcr.io/fission/node-env" + version = 3 + poolsize = 3 +} + +resource "fission_environment" "ruby" { + name = "ecom-ruby-env" + image = "ghcr.io/fission/ruby-env" + version = 3 + poolsize = 2 +} + +resource "fission_environment" "go" { + name = "ecom-go-env" + image = "ghcr.io/fission/go-env" + builder_image = "ghcr.io/fission/go-builder" + builder_command = "build" + version = 3 + poolsize = 1 +} diff --git a/examples/big-suite/functions_async.tf b/examples/big-suite/functions_async.tf new file mode 100644 index 0000000..07cf882 --- /dev/null +++ b/examples/big-suite/functions_async.tf @@ -0,0 +1,59 @@ +# ────────────────────────────────────────────────────────────────────────────── +# Async layer: notifier (ruby) + report-daily (go) +# +# Обе функции depends_on order-create — запускаются ПАРАЛЛЕЛЬНО между собой, +# но только после того как order-create (Go) задеплоен. +# Моделируют async side-effects после создания заказа. +# ────────────────────────────────────────────────────────────────────────────── + +# ─── notifier ───────────────────────────────────────────────────────────────── + +resource "fission_package" "notifier" { + name = "ecom-notifier-pkg" + environment = fission_environment.ruby.name + source_dir = "${path.module}/code/ruby-notifier" + + depends_on = [fission_http_trigger.order_create] +} + +resource "fission_function" "notifier" { + name = "ecom-notifier" + environment = fission_environment.ruby.name + package_name = fission_package.notifier.name + entrypoint = "handler" +} + +resource "fission_http_trigger" "notifier" { + name = "ecom-notifier-route" + function = fission_function.notifier.name + url = "/ecom/notify" + methods = ["POST"] +} + +# ─── report-daily (Go) ──────────────────────────────────────────────────────── +# Второй Go-пакет — использует тот же ecom-go-env. +# Билдится параллельно с notifier, но после order-create. + +resource "fission_package" "report_daily" { + name = "ecom-report-daily-pkg" + environment = fission_environment.go.name + source_dir = "${path.module}/code/go-report" + deploy_type = "source" + build_command = "build" + + depends_on = [fission_http_trigger.order_create] +} + +resource "fission_function" "report_daily" { + name = "ecom-report-daily" + environment = fission_environment.go.name + package_name = fission_package.report_daily.name + entrypoint = "Handler" +} + +resource "fission_http_trigger" "report_daily" { + name = "ecom-report-daily-route" + function = fission_function.report_daily.name + url = "/ecom/reports/daily" + methods = ["GET"] +} diff --git a/examples/big-suite/functions_checkout.tf b/examples/big-suite/functions_checkout.tf new file mode 100644 index 0000000..8707540 --- /dev/null +++ b/examples/big-suite/functions_checkout.tf @@ -0,0 +1,61 @@ +# ────────────────────────────────────────────────────────────────────────────── +# Checkout layer: payment-validate (node) → order-create (go) +# +# payment-validate — depends_on cart-service +# order-create — depends_on payment-validate +# Go builder не запускается пока checkout chain не готов. +# Самый долгий шаг (~40s на сборку) — намеренно в конце. +# ────────────────────────────────────────────────────────────────────────────── + +# ─── payment-validate ───────────────────────────────────────────────────────── + +resource "fission_package" "payment_validate" { + name = "ecom-payment-validate-pkg" + environment = fission_environment.node.name + source_dir = "${path.module}/code/node-payment" + + depends_on = [fission_http_trigger.cart_service] +} + +resource "fission_function" "payment_validate" { + name = "ecom-payment-validate" + environment = fission_environment.node.name + package_name = fission_package.payment_validate.name + entrypoint = "main" +} + +resource "fission_http_trigger" "payment_validate" { + name = "ecom-payment-validate-route" + function = fission_function.payment_validate.name + url = "/ecom/payment/validate" + methods = ["POST"] +} + +# ─── order-create (Go) ──────────────────────────────────────────────────────── +# Go builder pipeline: source zip → go-builder → .so плагин +# Намеренно в самом конце — запускать тяжёлый builder только когда весь +# checkout chain гарантированно развёрнут + +resource "fission_package" "order_create" { + name = "ecom-order-create-pkg" + environment = fission_environment.go.name + source_dir = "${path.module}/code/go-order" + deploy_type = "source" + build_command = "build" + + depends_on = [fission_http_trigger.payment_validate] +} + +resource "fission_function" "order_create" { + name = "ecom-order-create" + environment = fission_environment.go.name + package_name = fission_package.order_create.name + entrypoint = "Handler" +} + +resource "fission_http_trigger" "order_create" { + name = "ecom-order-create-route" + function = fission_function.order_create.name + url = "/ecom/orders" + methods = ["POST"] +} diff --git a/examples/big-suite/functions_core.tf b/examples/big-suite/functions_core.tf new file mode 100644 index 0000000..49d413d --- /dev/null +++ b/examples/big-suite/functions_core.tf @@ -0,0 +1,49 @@ +# ────────────────────────────────────────────────────────────────────────────── +# Core layer: auth-check (python) + rate-limiter (node) +# Деплоятся ПАРАЛЛЕЛЬНО — нет зависимостей друг от друга. +# Все сервисные функции depends_on этих триггеров. +# ────────────────────────────────────────────────────────────────────────────── + +# ─── auth-check ─────────────────────────────────────────────────────────────── + +resource "fission_package" "auth_check" { + name = "ecom-auth-check-pkg" + environment = fission_environment.python.name + source_dir = "${path.module}/code/python-auth" +} + +resource "fission_function" "auth_check" { + name = "ecom-auth-check" + environment = fission_environment.python.name + package_name = fission_package.auth_check.name + entrypoint = "main.main" +} + +resource "fission_http_trigger" "auth_check" { + name = "ecom-auth-check-route" + function = fission_function.auth_check.name + url = "/ecom/auth/check" + methods = ["GET", "POST"] +} + +# ─── rate-limiter ───────────────────────────────────────────────────────────── + +resource "fission_package" "rate_limiter" { + name = "ecom-rate-limiter-pkg" + environment = fission_environment.node.name + source_dir = "${path.module}/code/node-ratelimiter" +} + +resource "fission_function" "rate_limiter" { + name = "ecom-rate-limiter" + environment = fission_environment.node.name + package_name = fission_package.rate_limiter.name + entrypoint = "main" +} + +resource "fission_http_trigger" "rate_limiter" { + name = "ecom-rate-limiter-route" + function = fission_function.rate_limiter.name + url = "/ecom/rate/check" + methods = ["GET", "POST"] +} diff --git a/examples/big-suite/functions_infra.tf b/examples/big-suite/functions_infra.tf new file mode 100644 index 0000000..57b3ee7 --- /dev/null +++ b/examples/big-suite/functions_infra.tf @@ -0,0 +1,24 @@ +# ────────────────────────────────────────────────────────────────────────────── +# Infra layer: health-check (node) +# Полностью независима — деплоится параллельно со всем остальным. +# ────────────────────────────────────────────────────────────────────────────── + +resource "fission_package" "health" { + name = "ecom-health-pkg" + environment = fission_environment.node.name + source_dir = "${path.module}/code/node-health" +} + +resource "fission_function" "health" { + name = "ecom-health" + environment = fission_environment.node.name + package_name = fission_package.health.name + entrypoint = "main" +} + +resource "fission_http_trigger" "health" { + name = "ecom-health-route" + function = fission_function.health.name + url = "/ecom/health" + methods = ["GET"] +} diff --git a/examples/big-suite/functions_services.tf b/examples/big-suite/functions_services.tf new file mode 100644 index 0000000..6a31f63 --- /dev/null +++ b/examples/big-suite/functions_services.tf @@ -0,0 +1,85 @@ +# ────────────────────────────────────────────────────────────────────────────── +# Services layer: user-get, product-catalog, cart-service +# +# user-get — depends_on auth-check (нужно что auth маршрут доступен) +# product-catalog — depends_on rate-limiter +# cart-service — depends_on ОБОИХ: user-get + product-catalog +# (cart нужны оба сервиса — деплой только когда оба готовы) +# ────────────────────────────────────────────────────────────────────────────── + +# ─── user-get ───────────────────────────────────────────────────────────────── + +resource "fission_package" "user_get" { + name = "ecom-user-get-pkg" + environment = fission_environment.python.name + source_dir = "${path.module}/code/python-user" + + depends_on = [fission_http_trigger.auth_check] +} + +resource "fission_function" "user_get" { + name = "ecom-user-get" + environment = fission_environment.python.name + package_name = fission_package.user_get.name + entrypoint = "main.main" +} + +resource "fission_http_trigger" "user_get" { + name = "ecom-user-get-route" + function = fission_function.user_get.name + url = "/ecom/users" + methods = ["GET"] +} + +# ─── product-catalog ────────────────────────────────────────────────────────── + +resource "fission_package" "product_catalog" { + name = "ecom-product-catalog-pkg" + environment = fission_environment.ruby.name + source_dir = "${path.module}/code/ruby-product" + + depends_on = [fission_http_trigger.rate_limiter] +} + +resource "fission_function" "product_catalog" { + name = "ecom-product-catalog" + environment = fission_environment.ruby.name + package_name = fission_package.product_catalog.name + entrypoint = "handler" +} + +resource "fission_http_trigger" "product_catalog" { + name = "ecom-product-catalog-route" + function = fission_function.product_catalog.name + url = "/ecom/products" + methods = ["GET"] +} + +# ─── cart-service ───────────────────────────────────────────────────────────── +# user-get и product-catalog деплоятся ПАРАЛЛЕЛЬНО, +# cart ждёт пока ОБА готовы + +resource "fission_package" "cart_service" { + name = "ecom-cart-service-pkg" + environment = fission_environment.python.name + source_dir = "${path.module}/code/python-cart" + + depends_on = [ + fission_http_trigger.user_get, + fission_http_trigger.product_catalog, + ] +} + +resource "fission_function" "cart_service" { + name = "ecom-cart-service" + environment = fission_environment.python.name + package_name = fission_package.cart_service.name + entrypoint = "main.main" +} + +resource "fission_http_trigger" "cart_service" { + name = "ecom-cart-service-route" + function = fission_function.cart_service.name + url = "/ecom/cart" + methods = ["GET", "POST", "DELETE"] +} diff --git a/examples/big-suite/main.tf b/examples/big-suite/main.tf new file mode 100644 index 0000000..cf95448 --- /dev/null +++ b/examples/big-suite/main.tf @@ -0,0 +1,13 @@ +terraform { + required_providers { + fission = { + source = "nail/fission" + version = "~> 0.1.0" + } + } +} + +provider "fission" { + kubeconfig_path = "/home/naeel/.kube/config" + namespace = "default" +} diff --git a/examples/big-suite/outputs.tf b/examples/big-suite/outputs.tf new file mode 100644 index 0000000..9af2f4f --- /dev/null +++ b/examples/big-suite/outputs.tf @@ -0,0 +1,32 @@ +output "deploy_order" { + description = "Порядок деплоя Terraform (граф зависимостей)" + value = { + "1_parallel" = "auth-check, rate-limiter, health (независимые)" + "2_parallel" = "user-get (after auth), product-catalog (after rate-limiter)" + "3_sequential" = "cart-service (after user-get AND product-catalog)" + "4_sequential" = "payment-validate (after cart)" + "5_sequential" = "order-create — Go builder (after payment)" + "6_parallel" = "notifier, report-daily (оба after order-create)" + } +} + +output "urls" { + description = "HTTP-маршруты всех функций" + value = { + # Core + auth_check = "https://fission.kube5s.ru/ecom/auth/check" + rate_limiter = "https://fission.kube5s.ru/ecom/rate/check" + # Services + "user_get" = "https://fission.kube5s.ru/ecom/users?id=user-1" + product_catalog = "https://fission.kube5s.ru/ecom/products" + cart_service = "https://fission.kube5s.ru/ecom/cart" + # Checkout + payment_validate = "https://fission.kube5s.ru/ecom/payment/validate" + order_create = "https://fission.kube5s.ru/ecom/orders" + # Async + notifier = "https://fission.kube5s.ru/ecom/notify" + report_daily = "https://fission.kube5s.ru/ecom/reports/daily" + # Infra + health = "https://fission.kube5s.ru/ecom/health" + } +} diff --git a/terraform/provider/internal/resources/package_resource.go b/terraform/provider/internal/resources/package_resource.go index 0fea3cc..8676437 100644 --- a/terraform/provider/internal/resources/package_resource.go +++ b/terraform/provider/internal/resources/package_resource.go @@ -6,6 +6,7 @@ import ( "context" "crypto/sha256" "encoding/base64" + "encoding/json" "fmt" "io" "os" @@ -353,6 +354,11 @@ func loadPackageLiteral(sourceDir, codePath string) ([]byte, error) { return nil, fmt.Errorf("read source_dir source file %q: %w", mainFilePath, err) } + // Node.js runtime ожидает ZIP с package.json + main.js (ESM wrapper). + if filepath.Ext(mainFilePath) == ".js" { + return buildJSDeployZip(string(mainFileBytes)) + } + return mainFileBytes, nil } @@ -364,6 +370,52 @@ func loadPackageLiteral(sourceDir, codePath string) ([]byte, error) { return literalBytes, nil } +// buildJSDeployZip wraps Node.js user code into a ZIP with package.json (ESM) + main.js wrapper. +// This matches the format expected by ghcr.io/fission/node-env runtime. +func buildJSDeployZip(code string) ([]byte, error) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + pkgfw, err := zw.Create("package.json") + if err != nil { + return nil, err + } + if _, err := pkgfw.Write([]byte(`{"type":"module"}`)); err != nil { + return nil, err + } + + codeJSON, err := json.Marshal(code) + if err != nil { + return nil, fmt.Errorf("marshal user code: %w", err) + } + wrapper := fmt.Sprintf(`const __mod = { exports: {} }; +(new Function('module', 'exports', %s))(__mod, __mod.exports); +const _fn = __mod.exports; + +export default async function(ctx) { + const fn = typeof _fn === 'function' ? _fn : (_fn.default || _fn.handler || _fn.main); + if (!fn) throw new Error('no exported function found in user code'); + const result = await fn(ctx); + if (!result) return { status: 200, body: '' }; + if (typeof result.status !== 'undefined') return result; + return { status: 200, ...result }; +} +`, string(codeJSON)) + + fw, err := zw.Create("main.js") + if err != nil { + return nil, err + } + if _, err := fw.Write([]byte(wrapper)); err != nil { + return nil, err + } + + if err := zw.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + // loadPackageSourceArchive создает zip-архив из source_dir для builder pipeline. func loadPackageSourceArchive(sourceDir string) ([]byte, error) { var buf bytes.Buffer