diff --git a/examples/POSTGRES/code/calc-node/handler.js b/examples/POSTGRES/code/calc-node/handler.js
new file mode 100644
index 0000000..7ee3462
--- /dev/null
+++ b/examples/POSTGRES/code/calc-node/handler.js
@@ -0,0 +1,116 @@
+// Создано: 2026-04-10
+// Простой калькулятор — возвращает HTML страницу с кнопками.
+// GET → HTML; POST с {a, op, b} → вычисление с результатом на странице.
+// Рантайм nodejs20 v0.1.3+ поддерживает HTML-ответ (строка начинающаяся с '<').
+
+'use strict';
+
+const HTML = `
+
+
+
+
+Калькулятор (Node.js)
+
+
+
+
+
Калькулятор
+
Node.js 20
+
0
+
RESULT_LINE
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+// Форматирует число — убирает лишний .0 для целых
+function fmt(n) { return n === Math.trunc(n) ? String(Math.trunc(n)) : String(n); }
+
+function buildPage(resultLine) {
+ return HTML.replace('RESULT_LINE', resultLine || '');
+}
+
+function compute(event) {
+ // POST: тело приходит как JSON; поля a, op, b — строки из form
+ const a = parseFloat(event.a);
+ const b = parseFloat(event.b);
+ const op = event.op;
+ if (isNaN(a) || isNaN(b)) return buildPage('Некорректное число');
+ let res;
+ switch (op) {
+ case '+': res = a + b; break;
+ case '-': res = a - b; break;
+ case '*': res = a * b; break;
+ case '/':
+ if (b === 0) return buildPage('Деление на ноль');
+ res = a / b; break;
+ default: return buildPage('Неизвестный оператор');
+ }
+ return buildPage(`${fmt(a)} ${op} ${fmt(b)} = ${fmt(res)}`);
+}
+
+module.exports.handler = async function handler(event) {
+ if (event._method === 'POST') return compute(event);
+ return buildPage('');
+};
diff --git a/examples/POSTGRES/code/calc-node/package.json b/examples/POSTGRES/code/calc-node/package.json
new file mode 100644
index 0000000..18a1e41
--- /dev/null
+++ b/examples/POSTGRES/code/calc-node/package.json
@@ -0,0 +1,3 @@
+{
+ "dependencies": {}
+}
diff --git a/examples/POSTGRES/code/calc-python/handler.py b/examples/POSTGRES/code/calc-python/handler.py
new file mode 100644
index 0000000..57179b3
--- /dev/null
+++ b/examples/POSTGRES/code/calc-python/handler.py
@@ -0,0 +1,120 @@
+# Создано: 2026-04-10
+# Простой калькулятор — возвращает HTML страницу с кнопками.
+# Роутинг: GET → HTML страница; POST с {a, op, b} → вычисление и редирект с результатом.
+# _method приходит от python3.11 рантайма в event.
+
+_HTML = """
+
+
+
+
+Калькулятор (Python)
+
+
+
+
+
Калькулятор
+
Python 3.11
+
0
+
{result_line}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+def handler(event):
+ # POST: тело приходит как JSON от рантайма
+ if event.get('_method') == 'POST':
+ return _compute(event)
+ # GET: показываем чистую страницу
+ return _html()
+
+
+def _html(result_line='', fresh_js='true'):
+ return _HTML.replace('{result_line}', result_line).replace('{fresh_js}', fresh_js)
+
+
+def _compute(event):
+ try:
+ a = float(event.get('a', 0))
+ b = float(event.get('b', 0))
+ op = event.get('op', '')
+ if op == '+':
+ res = a + b
+ elif op == '-':
+ res = a - b
+ elif op == '*':
+ res = a * b
+ elif op == '/':
+ if b == 0:
+ return _html('Деление на ноль', 'false')
+ res = a / b
+ else:
+ return _html('Неизвестный оператор', 'false')
+ # Убираем лишний .0 для целых результатов
+ res_str = str(int(res)) if res == int(res) else str(res)
+ return _html(f'{int(a) if a == int(a) else a} {op} {int(b) if b == int(b) else b} = {res_str}', 'false')
+ except (ValueError, TypeError) as exc:
+ return _html(f'Ошибка: {exc}', 'false')
diff --git a/examples/POSTGRES/code/calc-python/requirements.txt b/examples/POSTGRES/code/calc-python/requirements.txt
new file mode 100644
index 0000000..d45663c
--- /dev/null
+++ b/examples/POSTGRES/code/calc-python/requirements.txt
@@ -0,0 +1 @@
+# нет внешних зависимостей
diff --git a/examples/POSTGRES/functions.tf b/examples/POSTGRES/functions.tf
new file mode 100644
index 0000000..b61f5b3
--- /dev/null
+++ b/examples/POSTGRES/functions.tf
@@ -0,0 +1,32 @@
+# Создано: 2026-04-10
+# functions.tf — sless_service ресурсы для примера POSTGRES.
+# Здесь: два калькуляторa — Python и Node.js.
+# sless_service = long-running Deployment + постоянный URL (в отличие от sless_function).
+
+# ─── Python-калькулятор ──────────────────────────────────────────────────────
+
+resource "sless_service" "calc_python" {
+ name = "calc-python"
+ runtime = "python3.11"
+ entrypoint = "handler.handler"
+ source_dir = "${path.module}/code/calc-python"
+}
+
+output "calc_python_url" {
+ description = "URL Python-калькулятора"
+ value = sless_service.calc_python.url
+}
+
+# ─── Node.js-калькулятор ─────────────────────────────────────────────────────
+
+resource "sless_service" "calc_node" {
+ name = "calc-node"
+ runtime = "nodejs20"
+ entrypoint = "handler.handler"
+ source_dir = "${path.module}/code/calc-node"
+}
+
+output "calc_node_url" {
+ description = "URL Node.js-калькулятора"
+ value = sless_service.calc_node.url
+}
diff --git a/examples/VM/README.md b/examples/VM/README.md
new file mode 100644
index 0000000..d5fe144
--- /dev/null
+++ b/examples/VM/README.md
@@ -0,0 +1,93 @@
+# Пример: Виртуальная машина (vApp + VM) в Nubes vDC
+
+Создаёт:
+- **vApp** — виртуальный каталог (контейнер для ВМ в VMware vDC)
+- **ВМ** — Ubuntu 22.04, 2 CPU / 2 GB RAM / 20 GB disk
+
+---
+
+## Что нужно сделать перед запуском
+
+### 1. Сгенерировать SSH-ключ
+
+Публичный ключ прописывается в ВМ при создании — это единственный способ зайти по SSH.
+Приватный ключ нужен хранить у себя.
+
+```bash
+ssh-keygen -t ed25519 -f ~/.ssh/sless-demo-vm -N "" -C "sless-demo-vm"
+```
+
+Публичный ключ (`~/.ssh/sless-demo-vm.pub`) — строка вида:
+```
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... sless-demo-vm
+```
+
+### 2. Заполнить terraform.tfvars
+
+Открыть файл `terraform.tfvars` и заменить значения:
+
+```hcl
+# Ваш API-токен из панели Nubes
+api_token = "ВСТАВИТЬ_ТОКЕН"
+
+# Публичный ключ из шага 1
+vm_public_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA..."
+```
+
+> **Токен** — берётся в панели Nubes: профиль → API-токены.
+> **Ключ** — содержимое файла `~/.ssh/sless-demo-vm.pub` (публичный, не приватный!).
+
+---
+
+## Запуск
+
+```bash
+cd examples/VM
+
+terraform init
+terraform apply
+```
+
+После `apply` в выводе будет:
+
+```
+Outputs:
+
+vm_id = "..."
+vm_state = {
+ "externalIp" = "1.2.3.4"
+ ...
+}
+vapp_id = "..."
+```
+
+---
+
+## Подключение по SSH
+
+```bash
+ssh -i ~/.ssh/sless-demo-vm ubuntu@
+```
+
+Логин — `ubuntu` (задан в `vm.tf`).
+
+---
+
+## Удаление
+
+```bash
+terraform destroy
+```
+
+Порядок автоматический: сначала suspend → потом delete. Без suspend удаление упадёт с ошибкой — это поведение Nubes, параметр `suspend_on_destroy = true` в ресурсах решает это.
+
+---
+
+## Что можно менять
+
+| Параметр | Файл | Примечание |
+|----------|------|-----------|
+| `vm_cpu`, `vm_ram`, `vm_disk` | `vm.tf` | Можно менять и переприменять |
+| `resource_name`, `vapp_name` | `vapp.tf` | **Не изменяется после создания** |
+| `image_vm`, `user_login`, `user_public_key` | `vm.tf` | **Не изменяется после создания** |
+| `vdc_uid`, `nsxt_uid` | `vapp.tf` | **Не изменяется после создания** |
diff --git a/examples/VM/main.tf b/examples/VM/main.tf
new file mode 100644
index 0000000..2fcef81
--- /dev/null
+++ b/examples/VM/main.tf
@@ -0,0 +1,38 @@
+// 2026-03-25 — main.tf для примера с vApp + ВМ (Виртуальный датацентр Nubes).
+// Провайдер nubes. Sless-провайдер не нужен — пример чисто инфраструктурный.
+
+terraform {
+ required_providers {
+ nubes = {
+ source = "terra.k8c.ru/nubes/nubes"
+ version = "5.0.31"
+ }
+ }
+}
+
+# ------------------------------------------------------------------
+# Переменные
+# ------------------------------------------------------------------
+
+variable "vm_public_key" {
+ type = string
+ sensitive = true
+ description = "Публичный SSH-ключ для ВМ. Приватный ключ: ~/terra/sless/examples/VM/vm_key"
+}
+
+variable "api_token" {
+ type = string
+ sensitive = true
+ description = "Nubes API token"
+}
+
+# ------------------------------------------------------------------
+# Провайдер
+# ------------------------------------------------------------------
+
+# API Dashboard (для Terraform-провайдеров): https://deck-api-test.ngcloud.ru/api/v1/index.cfm
+# UI облака (только браузер): https://deck-test.ngcloud.ru/
+provider "nubes" {
+ api_token = var.api_token
+ api_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm"
+}
diff --git a/examples/VM/vapp.tf b/examples/VM/vapp.tf
new file mode 100644
index 0000000..59b6c30
--- /dev/null
+++ b/examples/VM/vapp.tf
@@ -0,0 +1,28 @@
+// 2026-03-25 — vapp.tf: виртуальный каталог ВМ (vApp) в Nubes vDC.
+// nubes_vapp — контейнер для ВМ внутри Виртуального датацентра.
+// Обязательные поля: vdc_uid, nsxt_uid, vapp_name, resource_name.
+
+resource "nubes_vapp" "vapp" {
+ resource_name = "vm-sless-demo-vapp"
+ vapp_name = "vapp-sless-demo" # Уникальное в рамках организации. Не изменяется после создания.
+ vdc_uid = "e3c9e4f1-24da-4992-a003-f8a2a803a5f0" # UUID Услуги «Виртуальный датацентр (vDC)». Не изменяется после создания.
+ nsxt_uid = "0fe88e2a-31b6-4385-ad52-e27c6c0d38a6" # UUID Услуги «Сетевой шлюз периметра (Edge)». Не изменяется после создания.
+
+ adopt_existing_on_create = true
+ operation_timeout = "15m"
+
+ # ВАЖНО: delete без предварительного suspend завершается ошибкой
+ # "Невозможно выполнить операцию удаления услуги. Услуга не остановлена"
+ # suspend_on_destroy гарантирует правильный порядок: suspend → delete.
+ suspend_on_destroy = true
+}
+
+output "vapp_id" {
+ value = nubes_vapp.vapp.id
+ description = "ID созданного vApp (используется как vapp_uid при создании ВМ)"
+}
+
+output "vapp_state" {
+ value = nubes_vapp.vapp.state_out_flat
+ description = "Плоский state vApp — адреса, статусы сети и т.д."
+}
diff --git a/examples/VM/vm.tf b/examples/VM/vm.tf
new file mode 100644
index 0000000..0746817
--- /dev/null
+++ b/examples/VM/vm.tf
@@ -0,0 +1,34 @@
+// 2026-03-25 — vm.tf: виртуальная машина (nubes_vc_vm_v3) внутри vApp.
+// Зависит от nubes_vapp.vapp — создаётся после vApp.
+// image_vm, vapp_uid, user_public_key не изменяются после создания.
+
+resource "nubes_vc_vm_v3" "vm" {
+ resource_name = "vm-sless-demo"
+
+ vapp_uid = nubes_vapp.vapp.id # ссылка на vApp. Не изменяется после создания.
+ image_vm = "Ubuntu 22.04 LTS" # Не изменяется после создания.
+ ip_space_name = "internet-ipv4-v1"
+
+ user_login = "ubuntu"
+ user_public_key = var.vm_public_key # задаётся в terraform.tfvars
+
+ vm_cpu = 2
+ vm_ram = 2 # GB
+ vm_disk = 20 # GB
+
+ adopt_existing_on_create = true
+ operation_timeout = "15m"
+
+ # delete без предварительного suspend завершается ошибкой (аналогично vApp).
+ suspend_on_destroy = true
+}
+
+output "vm_id" {
+ value = nubes_vc_vm_v3.vm.id
+ description = "ID созданной ВМ"
+}
+
+output "vm_state" {
+ value = nubes_vc_vm_v3.vm.state_out_flat
+ description = "Плоский state ВМ — IP-адреса, статус и т.д."
+}
diff --git a/runtimes/nodejs20/server.js b/runtimes/nodejs20/server.js
index 34f2431..db23b88 100644
--- a/runtimes/nodejs20/server.js
+++ b/runtimes/nodejs20/server.js
@@ -1,4 +1,5 @@
// Изменено: 2026-03-09
+// Изменено: 2026-04-10 (v0.1.3) — sendResult: если handler вернул строку начинающуюся с '<' → text/html.
// HTTP-обёртка для serverless функций на Node.js 20.
// Загружает модуль из SLESS_ENTRYPOINT или handler.js по умолчанию.
// Формат SLESS_ENTRYPOINT: "module-name.functionName" (например: handler-http.handle)
@@ -65,7 +66,7 @@ const server = http.createServer(async (req, res) => {
try {
const result = await userHandle(event);
- sendJSON(res, 200, result);
+ sendResult(res, 200, result);
} catch (err) {
console.error('Handler error:', err);
sendJSON(res, 500, { error: err.message });
@@ -73,6 +74,21 @@ const server = http.createServer(async (req, res) => {
});
});
+function sendResult(res, status, data) {
+ // Если handler вернул строку начинающуюся с '<' — HTML страница.
+ // Если строка, но не HTML — text/plain. Иначе — JSON.
+ if (typeof data === 'string') {
+ const ctype = data.trimStart().startsWith('<')
+ ? 'text/html; charset=utf-8'
+ : 'text/plain; charset=utf-8';
+ const buf = Buffer.from(data, 'utf-8');
+ res.writeHead(status, { 'Content-Type': ctype, 'Content-Length': buf.length });
+ res.end(buf);
+ return;
+ }
+ sendJSON(res, status, data);
+}
+
function sendJSON(res, status, data) {
const body = JSON.stringify(data);
res.writeHead(status, {