From 22c7e92590aef6ab7f6e7f30f0e08ee1e033636e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sat, 7 Mar 2026 17:00:29 +0400 Subject: [PATCH] feat: add nodejs20 runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - runtimes/nodejs20/server.js: HTTP wrapper, exports.handle(event) - runtimes/nodejs20/Dockerfile: node:20-alpine base image - naeel/sless-runtime-nodejs20:v0.1.0 pushed to DockerHub - upload.go: nodejs20 in runtimeBaseImage(), package.json → npm install - upload.go: python3.11 now uses v0.1.0 tag (no more latest) - operator v0.1.2 deployed in cluster - E2E: hello-node-default.fn.kube5s.ru → {"message":"Hello, Naeel! (nodejs20)"} --- deployments/k8s/operator.yaml | 2 +- examples/hello-node/handler.js | 6 +++ examples/hello-node/main.tf | 45 +++++++++++++++++++++ internal/api/handler/upload.go | 41 ++++++++++++------- runtimes/nodejs20/Dockerfile | 15 +++++++ runtimes/nodejs20/server.js | 73 ++++++++++++++++++++++++++++++++++ 6 files changed, 166 insertions(+), 16 deletions(-) create mode 100644 examples/hello-node/handler.js create mode 100644 examples/hello-node/main.tf create mode 100644 runtimes/nodejs20/Dockerfile create mode 100644 runtimes/nodejs20/server.js diff --git a/deployments/k8s/operator.yaml b/deployments/k8s/operator.yaml index 407aa81..03aa0f9 100644 --- a/deployments/k8s/operator.yaml +++ b/deployments/k8s/operator.yaml @@ -67,7 +67,7 @@ spec: containers: - name: operator # При обновлении версии оператора — менять тег здесь (не latest!) - image: naeel/sless-operator:v0.1.1 + image: naeel/sless-operator:v0.1.2 # Always — чтобы всегда тянуть по точному тегу (не кешировать старый) imagePullPolicy: Always ports: diff --git a/examples/hello-node/handler.js b/examples/hello-node/handler.js new file mode 100644 index 0000000..415c92f --- /dev/null +++ b/examples/hello-node/handler.js @@ -0,0 +1,6 @@ +// handler.js — пример serverless функции на Node.js 20 +// Возвращает приветствие с именем из event или "World" по умолчанию +exports.handle = async (event) => { + const name = event.name || 'World'; + return { message: `Hello, ${name}! (nodejs20)` }; +}; diff --git a/examples/hello-node/main.tf b/examples/hello-node/main.tf new file mode 100644 index 0000000..9c3bab8 --- /dev/null +++ b/examples/hello-node/main.tf @@ -0,0 +1,45 @@ +# 2026-03-07 +# main.tf — e2e тест: hello-world функция на Node.js 20. +# +# Использование: +# 1. terraform init && terraform apply +# 2. После apply (~2 мин kaniko): +# curl -s -X POST -H 'Content-Type: application/json' -d '{"name":"Naeel"}' +# Ожидаемый ответ: {"message":"Hello, Naeel! (nodejs20)"} + +terraform { + required_providers { + sless = { + source = "terra.k8c.ru/naeel/sless" + version = "~> 0.1.1" + } + } +} + +provider "sless" { + endpoint = "https://sless-api.kube5s.ru" + token = "dev-token-change-me" +} + +resource "sless_function" "hello_node" { + namespace = "default" + name = "hello-node" + runtime = "nodejs20" + entrypoint = "handler.handle" + memory_mb = 128 + timeout_sec = 30 + + code_path = "${path.module}/handler.zip" + code_hash = filemd5("${path.module}/handler.zip") +} + +resource "sless_trigger" "hello_node_http" { + namespace = "default" + name = "hello-node-http" + type = "http" + function = sless_function.hello_node.name +} + +output "trigger_url" { + value = sless_trigger.hello_node_http.url +} diff --git a/internal/api/handler/upload.go b/internal/api/handler/upload.go index 844ce92..900c2c6 100644 --- a/internal/api/handler/upload.go +++ b/internal/api/handler/upload.go @@ -26,31 +26,39 @@ import ( ) // runtimeBaseImage возвращает Docker образ базового runtime для данного runtime-идентификатора. -// Соглашение: образы лежат на DockerHub под аккаунтом naeel. +// Соглашение: образы лежат на DockerHub под аккаунтом naeel, тег = версия образа. // Возвращает ошибку если runtime не поддерживается — это граница валидации. func runtimeBaseImage(runtime string) (string, error) { switch runtime { case "python3.11": - return "naeel/sless-runtime-python3.11:latest", nil + return "naeel/sless-runtime-python3.11:v0.1.0", nil + case "nodejs20": + return "naeel/sless-runtime-nodejs20:v0.1.0", nil default: - return "", fmt.Errorf("unsupported runtime: %q (supported: python3.11)", runtime) + return "", fmt.Errorf("unsupported runtime: %q (supported: python3.11, nodejs20)", runtime) } } // generateDockerfile генерирует Dockerfile для kaniko. -// Базовый образ содержит HTTP-обёртку (server.py). +// Базовый образ содержит HTTP-обёртку (server.py / server.js). // Пользовательский код копируется в /app/function/ поверх базового образа. -// Если в zip есть requirements.txt — добавляем pip install (для python runtime). -func generateDockerfile(runtime string, hasRequirements bool) ([]byte, error) { +// Зависимости устанавливаются ПОСЛЕ COPY — чтобы кеш слоёв работал при повторных сборках. +func generateDockerfile(runtime string, hasRequirements bool, hasPackageJSON bool) ([]byte, error) { baseImage, err := runtimeBaseImage(runtime) if err != nil { return nil, err } content := fmt.Sprintf("FROM %s\nCOPY . /app/function/\n", baseImage) - // pip install только для python runtime и только если requirements.txt есть в zip. - // Делаем это ПОСЛЕ COPY чтобы воспользоваться кешем слоёв Docker при повторных сборках. - if hasRequirements && runtime == "python3.11" { - content += "RUN pip install --no-cache-dir -r /app/function/requirements.txt\n" + switch runtime { + case "python3.11": + if hasRequirements { + content += "RUN pip install --no-cache-dir -r /app/function/requirements.txt\n" + } + case "nodejs20": + if hasPackageJSON { + // cd нужен т.к. npm install читает package.json из текущей директории + content += "RUN cd /app/function && npm install --omit=dev\n" + } } return []byte(content), nil } @@ -151,19 +159,22 @@ func (h *Handler) UploadCode(w http.ResponseWriter, r *http.Request) { return } - // Проверяем есть ли requirements.txt в zip (для python runtime — pip install) - hasRequirements := false + // Сканируем zip на наличие файлов зависимостей для разных runtime + hasRequirements := false // requirements.txt — python3.11 + hasPackageJSON := false // package.json — nodejs20 if zr, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData))); err == nil { for _, f := range zr.File { - if f.Name == "requirements.txt" { + switch f.Name { + case "requirements.txt": hasRequirements = true - break + case "package.json": + hasPackageJSON = true } } } // Генерируем Dockerfile под runtime функции - dockerfileContent, err := generateDockerfile(fn.Spec.Runtime, hasRequirements) + dockerfileContent, err := generateDockerfile(fn.Spec.Runtime, hasRequirements, hasPackageJSON) if err != nil { writeJSON(w, http.StatusBadRequest, errResp(err.Error())) return diff --git a/runtimes/nodejs20/Dockerfile b/runtimes/nodejs20/Dockerfile new file mode 100644 index 0000000..3958fd2 --- /dev/null +++ b/runtimes/nodejs20/Dockerfile @@ -0,0 +1,15 @@ +# Изменено: 2026-03-07 +# Base runtime image для Node.js 20 serverless функций. +# Содержит только HTTP-обёртку (server.js). +# Пользовательский код докапывается kaniko поверх: COPY . /app/function/ +# Почему alpine: минимальный размер. node:20-alpine ~180MB vs node:20 ~1GB. + +FROM node:20-alpine + +WORKDIR /app + +COPY server.js /app/server.js + +EXPOSE 8080 + +CMD ["node", "/app/server.js"] diff --git a/runtimes/nodejs20/server.js b/runtimes/nodejs20/server.js new file mode 100644 index 0000000..290edc3 --- /dev/null +++ b/runtimes/nodejs20/server.js @@ -0,0 +1,73 @@ +// Изменено: 2026-03-07 +// HTTP-обёртка для serverless функций на Node.js 20. +// Загружает handler.js из /app/function/ и вызывает handle(event) на каждый запрос. +// Соглашение: handler.js должен экспортировать async функцию handle(event). +// +// Пример handler.js: +// exports.handle = async (event) => { +// return { message: `Hello, ${event.name || 'World'}!` }; +// }; + +'use strict'; + +const http = require('http'); +const path = require('path'); + +const HANDLER_PATH = '/app/function/handler.js'; +const PORT = 8080; + +// Загружаем модуль пользователя один раз при старте — не на каждый запрос +let userHandle; +try { + const mod = require(HANDLER_PATH); + if (typeof mod.handle !== 'function') { + throw new Error('handler.js must export a handle(event) function'); + } + userHandle = mod.handle; +} catch (err) { + console.error('Failed to load handler:', err.message); + process.exit(1); +} + +const server = http.createServer(async (req, res) => { + // Health check — используется readinessProbe оператора + if (req.method === 'GET' && req.url === '/health') { + return sendJSON(res, 200, { status: 'ok' }); + } + + // Читаем тело запроса + let body = ''; + req.on('data', chunk => { body += chunk; }); + req.on('end', async () => { + let event = {}; + if (body) { + try { + event = JSON.parse(body); + } catch { + // Не JSON — передаём как строку, не ломаем вызов + event = { body }; + } + } + + try { + const result = await userHandle(event); + sendJSON(res, 200, result); + } catch (err) { + console.error('Handler error:', err); + sendJSON(res, 500, { error: err.message }); + } + }); +}); + +function sendJSON(res, status, data) { + const body = JSON.stringify(data); + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }); + res.end(body); +} + +server.listen(PORT, '0.0.0.0', () => { + console.log(`sless runtime (nodejs20) listening on :${PORT}`); +});