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:
Naeel
2026-04-20 11:38:45 +03:00
parent 4837d42ba1
commit 01df1498d6
22 changed files with 1033 additions and 5 deletions
+70 -5
View File
@@ -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
+3
View File
@@ -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)
}
+3
View File
@@ -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
+33
View File
@@ -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
}
+59
View File
@@ -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"]
}
+61
View File
@@ -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"]
}
+49
View File
@@ -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"]
}
+24
View File
@@ -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"]
}
+85
View File
@@ -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"]
}
+13
View File
@@ -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"
}
+32
View File
@@ -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