feat: add nodejs20 runtime

- 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)"}
This commit is contained in:
“Naeel”
2026-03-07 17:00:29 +04:00
parent 97f38c1f72
commit 22c7e92590
6 changed files with 166 additions and 16 deletions
+1 -1
View File
@@ -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:
+6
View File
@@ -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)` };
};
+45
View File
@@ -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 <trigger_url> -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
}
+26 -15
View File
@@ -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
+15
View File
@@ -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"]
+73
View File
@@ -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}`);
});