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
This commit is contained in:
+70
-5
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/user/fn
|
||||
|
||||
go 1.23
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/user/fn
|
||||
|
||||
go 1.23
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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" },
|
||||
};
|
||||
};
|
||||
@@ -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" },
|
||||
};
|
||||
};
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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)
|
||||
@@ -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"}}
|
||||
@@ -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})
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user