From 554f4cdace5ecf132c276c36be012e714a35e754 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Wed, 11 Mar 2026 12:23:53 +0400 Subject: [PATCH] Sanitized examples update --- DESTROY_ROUTE_CLEANUP_BUG.md | 143 ++++++++ README.md | 160 +++++++++ hello-node/code/handler-http.js | 8 + hello-node/code/handler-job.js | 11 + hello-node/http.tf | 24 ++ hello-node/job.tf | 34 ++ hello-node/main.tf | 24 ++ hello-node/test_invalid.tf.disabled | 2 + hello-node/variables.tf | 10 + notes-python/code/notes-list/notes_list.py | 31 ++ notes-python/code/notes-list/requirements.txt | 1 + notes-python/code/notes/notes_crud.py | 81 +++++ notes-python/code/notes/requirements.txt | 1 + notes-python/code/sql-runner/requirements.txt | 1 + notes-python/code/sql-runner/sql_runner.py | 39 ++ notes-python/init.tf | 44 +++ notes-python/main.tf | 27 ++ notes-python/notes-list.tf | 22 ++ notes-python/notes.tf | 27 ++ notes-python/outputs.tf | 42 +++ notes-python/sql-runner.tf | 20 ++ notes-python/variables.tf | 23 ++ push-sample/Dockerfile | 7 + push-sample/README.md | 28 ++ push-sample/build_and_push.sh | 53 +++ run_terraform_examples.sh | 333 ++++++++++++++++++ simple-node/code/time_display/time_display.js | 20 ++ simple-node/code/time_getter/time_getter.js | 10 + simple-node/main.tf | 31 ++ simple-node/outputs.tf | 14 + simple-node/time-display.tf | 28 ++ simple-node/time-getter.tf | 27 ++ simple-node/variables.tf | 10 + .../code/time_display/time_display.py | 20 ++ simple-python/code/time_getter/time_getter.py | 18 + simple-python/main.tf | 30 ++ simple-python/outputs.tf | 14 + simple-python/time-display.tf | 28 ++ simple-python/time-getter.tf | 27 ++ simple-python/variables.tf | 10 + 40 files changed, 1483 insertions(+) create mode 100644 DESTROY_ROUTE_CLEANUP_BUG.md create mode 100644 README.md create mode 100644 hello-node/code/handler-http.js create mode 100644 hello-node/code/handler-job.js create mode 100644 hello-node/http.tf create mode 100644 hello-node/job.tf create mode 100644 hello-node/main.tf create mode 100644 hello-node/test_invalid.tf.disabled create mode 100644 hello-node/variables.tf create mode 100644 notes-python/code/notes-list/notes_list.py create mode 100644 notes-python/code/notes-list/requirements.txt create mode 100644 notes-python/code/notes/notes_crud.py create mode 100644 notes-python/code/notes/requirements.txt create mode 100644 notes-python/code/sql-runner/requirements.txt create mode 100644 notes-python/code/sql-runner/sql_runner.py create mode 100644 notes-python/init.tf create mode 100644 notes-python/main.tf create mode 100644 notes-python/notes-list.tf create mode 100644 notes-python/notes.tf create mode 100644 notes-python/outputs.tf create mode 100644 notes-python/sql-runner.tf create mode 100644 notes-python/variables.tf create mode 100644 push-sample/Dockerfile create mode 100644 push-sample/README.md create mode 100755 push-sample/build_and_push.sh create mode 100755 run_terraform_examples.sh create mode 100644 simple-node/code/time_display/time_display.js create mode 100644 simple-node/code/time_getter/time_getter.js create mode 100644 simple-node/main.tf create mode 100644 simple-node/outputs.tf create mode 100644 simple-node/time-display.tf create mode 100644 simple-node/time-getter.tf create mode 100644 simple-node/variables.tf create mode 100644 simple-python/code/time_display/time_display.py create mode 100644 simple-python/code/time_getter/time_getter.py create mode 100644 simple-python/main.tf create mode 100644 simple-python/outputs.tf create mode 100644 simple-python/time-display.tf create mode 100644 simple-python/time-getter.tf create mode 100644 simple-python/variables.tf diff --git a/DESTROY_ROUTE_CLEANUP_BUG.md b/DESTROY_ROUTE_CLEANUP_BUG.md new file mode 100644 index 0000000..4dba243 --- /dev/null +++ b/DESTROY_ROUTE_CLEANUP_BUG.md @@ -0,0 +1,143 @@ +# Bug Report: HTTP route is not removed after Terraform destroy + +## Summary + +При удалении примера `hello-node` через Terraform команда `terraform destroy` завершается успешно, но публичный HTTP endpoint не удаляется. + +Фактическое поведение после `destroy` такое: + +1. Сразу после удаления endpoint ещё некоторое время отвечает `HTTP 200` и возвращает корректный ответ функции. +2. Затем backend функции действительно исчезает, но публичный маршрут остаётся опубликованным и начинает отвечать `HTTP 502 function unreachable`. +3. Даже через 120 секунд endpoint не исчезает. + +Это выглядит как баг cleanup в platform/backend/provider lifecycle для HTTP trigger/route. + +## Affected Example + +- Example: `hello-node` +- Terraform files: `hello-node/main.tf`, `hello-node/http.tf`, `hello-node/job.tf` +- Public URL: `https://sless-api.kube5s.ru/fn/default/hello-http` +- Function name: `hello-http` +- Trigger name: `hello-http-trigger` + +## Reproduction + +Использовался репозиторий examples и скрипт: + +- Script: `./run_terraform_examples.sh` + +Шаги воспроизведения: + +1. Выполнить `terraform init` в `hello-node` +2. Выполнить `terraform apply` +3. Убедиться, что endpoint живой +4. Выполнить `terraform destroy` +5. Проверять публичный URL после destroy + +Логика проверки встроена в `run_terraform_examples.sh`: + +1. После `apply` endpoint обязан отвечать `200` +2. После `destroy` endpoint должен исчезнуть +3. Скрипт ждёт до 120 секунд и перепроверяет endpoint каждые 5 секунд + +## Expected Result + +После успешного `terraform destroy`: + +1. Публичный URL должен перестать существовать +2. Запрос на URL должен вернуть `404` или другой явный признак отсутствия маршрута +3. Provider не должен возвращать успешный destroy раньше, чем cleanup HTTP route завершён + +## Actual Result + +После успешного `terraform destroy`: + +1. Terraform сообщает `Destroy complete! Resources: 4 destroyed.` +2. Endpoint `https://sless-api.kube5s.ru/fn/default/hello-http` продолжает отвечать `200` +3. Через некоторое время тот же endpoint начинает отвечать `502` +4. Тело ответа на `502`: + +```json +{"error":"function unreachable: Post \"http://hello-http.sless-fn-default.svc.cluster.local:8080\": dial tcp 10.106.128.167:8080: connect: operation not permitted"} +``` + +Это означает: + +1. внешний HTTP маршрут всё ещё существует; +2. запрос по нему всё ещё направляется внутрь платформы; +3. backend функции уже удалён или недоступен; +4. cleanup маршрута не завершён. + +## Timeline From Real Run + +Подтверждённая последовательность из фактического прогона: + +1. `terraform destroy` завершился успешно +2. первые проверки после destroy возвращали `HTTP 200` +3. затем проверки начали возвращать `HTTP 502 function unreachable` +4. в течение всех 24 проверок по 5 секунд endpoint не исчез +5. итоговое время ожидания: 120 секунд + +Итоговый summary из скрипта: + +```text +ERROR SUMMARY +example: hello-node +step: endpoint cleanup after clean destroy +reason: route cleanup bug: public endpoint still exists but backend is already gone (HTTP 502 function unreachable); endpoint was still published after 120s +``` + +## Why This Is A Real Platform Bug + +Это не похоже на проблему тестового скрипта или Terraform CLI по следующим причинам: + +1. `terraform destroy` завершается без ошибки +2. state Terraform очищается как ожидалось +3. сначала endpoint отвечает `200`, значит маршрут реально жив после destroy +4. потом endpoint отвечает `502 function unreachable`, значит backend уже исчез, но route ещё остался +5. скрипт ждёт 120 секунд, то есть это не мгновенная eventual consistency на 1-2 секунды + +Иными словами: удаление backend и удаление публичного маршрута расходятся по времени, а route cleanup либо не выполняется, либо не дожидается завершения. + +## Most Likely Broken Layer + +Наиболее вероятные точки проблемы: + +1. API/backend destroy trigger возвращает success до фактического удаления HTTP route +2. Controller удаляет function workload, но не удаляет route/ingress/virtualservice/gateway mapping +3. Удаление route запускается асинхронно, но его результат не awaited +4. В системе остаётся запись маршрута на имя функции, хотя service/backend уже удалён + +## What To Check In The Development Repo + +Нужно проверить destroy flow именно для HTTP trigger: + +1. Удаляется ли объект trigger только в metadata/storage или реально удаляется и внешний маршрут +2. Какие Kubernetes/ingress объекты создаются для HTTP trigger и все ли они удаляются +3. Есть ли race condition между удалением function/service и удалением route +4. Не возвращает ли provider success раньше, чем backend подтверждает полное удаление маршрута +5. Есть ли финальный polling/wait на исчезновение route перед возвратом успешного destroy + +Если архитектура использует отдельные сущности route/service/function, то destroy должен идти в таком порядке: + +1. disable/remove public routing +2. дождаться, что endpoint больше не публикуется снаружи +3. удалить backend/service/workload +4. завершить destroy success + +Сейчас по фактическому поведению порядок либо обратный, либо неполный. + +## Minimal Acceptance Criteria For Fix + +Исправление можно считать рабочим, если после `terraform destroy` для `hello-node` выполняются все условия: + +1. URL `https://sless-api.kube5s.ru/fn/default/hello-http` перестаёт отвечать как живой маршрут +2. URL не возвращает `502 function unreachable` +3. URL исчезает в разумное время после destroy +4. `./run_terraform_examples.sh` проходит шаг `endpoint cleanup after clean destroy` + +## Current Status + +На данный момент массовый прогон examples корректно останавливается на `hello-node`, потому что это первый воспроизводимый failure. + +Дальше прогонять остальные примеры без исправления destroy cleanup смысла нет: тест уже доказал platform bug на базовом HTTP сценарии. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..afe6564 --- /dev/null +++ b/README.md @@ -0,0 +1,160 @@ +# Примеры sless + +## Что такое sless + +**sless** — платформа для запуска serverless-функций в Kubernetes-кластере. + +Код на Python или Node.js загружается в платформу, которая собирает Docker-образ, деплоит его в кластер и публикует HTTP-эндпоинт. Всё управляется через Terraform. + +### Ресурсы + +| Ресурс | Что делает | +|---|---| +| `sless_function` | Загружает код и собирает Docker-образ. Сама по себе не принимает запросы — нужен триггер или джоб | +| `sless_trigger` | Публикует функцию — либо как HTTP-эндпоинт, либо по расписанию (cron) | +| `sless_job` | Запускает функцию один раз (например, для инициализации БД) и ждёт результата | + +**Типичный сценарий:** `sless_function` с кодом + `sless_trigger` с `type = "http"` → публичный URL вида `https://sless-api.kube5s.ru/fn/default/имя-функции`. + +--- + +Примеры показывают различные сценарии использования serverless функций через Terraform провайдер `terra.k8c.ru/naeel/sless`. + +## Требования + +- Terraform >= 1.0 +- Доступ к `https://sless-api.kube5s.ru` + +## Провайдер + +Во всех примерах `main.tf` содержит: + +```hcl +provider "sless" { + endpoint = "https://sless-api.kube5s.ru" + token = "dev-token-change-me" +} +``` + +--- + +## Примеры + +### `simple-python` — джоб передаёт результат в HTTP-функцию (Python) + +При `apply` запускается джоб, его вывод передаётся в HTTP-функцию через `env_vars`. + +```bash +cd simple-python +terraform init +terraform apply -auto-approve + +# Что вернул джоб (время на момент деплоя): +terraform output job_result + +# Проверить функцию: +curl -s https://sless-api.kube5s.ru/fn/default/simple-py-time-display +``` + +--- + +### `simple-node` — то же самое, но на Node.js 20 + +```bash +cd simple-node +terraform init +terraform apply -auto-approve + +terraform output job_result +curl -s https://sless-api.kube5s.ru/fn/default/simple-node-time-display +``` + +--- + +### `hello-node` — минимальный пример на Node.js + +Две независимые функции: HTTP-функция (возвращает приветствие) и одноразовый джоб (суммирует числа). + +```bash +cd hello-node +terraform init +terraform apply -auto-approve + +# Проверить HTTP-функцию: +curl -s -X POST https://sless-api.kube5s.ru/fn/default/hello-http \ + -H 'Content-Type: application/json' -d '{"name":"World"}' + +# Посмотреть результат джоба: +terraform output job_message +``` + +--- + +### `notes-python` — CRUD API на Python + PostgreSQL + +Полноценное приложение: инициализация схемы БД через джобы, CRUD-функция, read-only функция для списка записей. + +**Переменные:** + +| Переменная | Описание | Дефолт | +|---|---|---| +| `pg_dsn` | DSN для подключения к PostgreSQL | `postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable` | + +```bash +cd notes-python +terraform init + +# Опционально — переопределить DSN: +# export TF_VAR_pg_dsn="postgres://user:pass@host:5432/db?sslmode=disable" + +terraform apply -auto-approve + +# Проверить инициализацию БД: +terraform output db_init_table_status +terraform output db_init_index_status + +# URL функций: +terraform output notes_url # CRUD +terraform output notes_list_url # список всех записей + +# Создать запись: +curl -s -X POST "https://sless-api.kube5s.ru/fn/default/notes/add?title=Hello&body=World" + +# Список записей: +curl -s https://sless-api.kube5s.ru/fn/default/notes-list + +# Обновить (id из предыдущего ответа): +curl -s -X POST "https://sless-api.kube5s.ru/fn/default/notes/update?id=1&title=Updated&body=New+body" + +# Удалить: +curl -s -X POST "https://sless-api.kube5s.ru/fn/default/notes/delete?id=1" +``` + +--- + +## Общие команды + +```bash +# Посмотреть текущее состояние ресурсов: +terraform show + +# Пересоздать конкретный ресурс: +terraform apply -replace=sless_function.имя -auto-approve + +# Повторно запустить джоб — увеличить run_id в .tf файле, затем: +terraform apply -auto-approve + +# Удалить все ресурсы примера: +terraform destroy -auto-approve +``` + +## Структура каждого примера + +``` +пример/ +├── main.tf — провайдер +├── *.tf — ресурсы (функции, триггеры, джобы) +├── outputs.tf — URLs и статусы после apply +├── variables.tf — входные переменные (если есть) +└── code/ — исходный код функций +``` diff --git a/hello-node/code/handler-http.js b/hello-node/code/handler-http.js new file mode 100644 index 0000000..16bc1a1 --- /dev/null +++ b/hello-node/code/handler-http.js @@ -0,0 +1,8 @@ +// 2026-03-08 +// handler-http.js — HTTP-функция: возвращает приветствие. +// Используется с sless_trigger (постоянный эндпоинт). +exports.handle = async (event) => { + const name = event.name || 'World'; + return { message: `Hello, ${name}! HTTP !!!` }; +}; + diff --git a/hello-node/code/handler-job.js b/hello-node/code/handler-job.js new file mode 100644 index 0000000..23abed8 --- /dev/null +++ b/hello-node/code/handler-job.js @@ -0,0 +1,11 @@ +// 2026-03-08 +// handler-job.js — batch-функция: суммирует числа и считает среднее. +// Используется с sless_job (одноразовый запуск). +// event.numbers — массив чисел, например [1, 2, 3, 4, 5] +exports.handle = async (event) => { + const numbers = event.numbers || []; + const sum = numbers.reduce((acc, n) => acc + n, 0); + const avg = numbers.length > 0 ? sum / numbers.length : 0; + return { input: numbers, sum, avg, count: numbers.length }; +}; + diff --git a/hello-node/http.tf b/hello-node/http.tf new file mode 100644 index 0000000..beee105 --- /dev/null +++ b/hello-node/http.tf @@ -0,0 +1,24 @@ +# 2026-03-08 / Изменено: 2026-03-09 +# http.tf — HTTP-функция: принимает запросы, возвращает приветствие. +# Код: code/handler-http.js + +resource "sless_function" "hello_http" { + name = "hello-http" + runtime = "nodejs20" + entrypoint = "handler-http.handle" + memory_mb = 128 + timeout_sec = 30 + + source_dir = "${path.module}/code" +} + +resource "sless_trigger" "hello_http" { + name = "hello-http-trigger" + type = "http" + function = sless_function.hello_http.name + enabled = true +} + +output "trigger_url" { + value = sless_trigger.hello_http.url +} diff --git a/hello-node/job.tf b/hello-node/job.tf new file mode 100644 index 0000000..843d02f --- /dev/null +++ b/hello-node/job.tf @@ -0,0 +1,34 @@ +# 2026-03-08 / Изменено: 2026-03-09 +# job.tf — одноразовая функция: суммирует числа из переданного массива. +# Код: code/handler-job.js + +resource "sless_function" "hello_job" { + name = "hello-job" + runtime = "nodejs20" + entrypoint = "handler-job.handle" + memory_mb = 128 + timeout_sec = 30 + + source_dir = "${path.module}/code" +} + +# Одноразовый запуск. Для повторного запуска увеличь run_id (1→2→3...). +resource "sless_job" "hello_run" { + name = "hello-run" + function = sless_function.hello_job.name + event_json = jsonencode({ numbers = [100, 200, 300] }) + wait_timeout_sec = 600 + run_id = 9 +} + +output "job_phase" { + value = sless_job.hello_run.phase +} + +output "job_message" { + value = sless_job.hello_run.message +} + +output "job_completion_time" { + value = sless_job.hello_run.completion_time +} diff --git a/hello-node/main.tf b/hello-node/main.tf new file mode 100644 index 0000000..5f4a402 --- /dev/null +++ b/hello-node/main.tf @@ -0,0 +1,24 @@ +# 2026-03-11 +# main.tf — провайдеры. +# Функции и их код определены в отдельных файлах: +# http.tf — HTTP-триггер (code/handler-http.js) +# job.tf — одноразовый запуск (code/handler-job.js) +# +# nubes_endpoint — провайдер делает GET запрос для валидации токена. +# Namespace вычисляется автоматически из JWT sub: sless-{sha256[:8]} + +terraform { + required_providers { + sless = { + source = "terra.k8c.ru/naeel/sless" + version = "~> 0.1.13" + } + } +} + +provider "sless" { + endpoint = "https://sless-api.kube5s.ru" + token = var.token + nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1" +} + diff --git a/hello-node/test_invalid.tf.disabled b/hello-node/test_invalid.tf.disabled new file mode 100644 index 0000000..7c5e5eb --- /dev/null +++ b/hello-node/test_invalid.tf.disabled @@ -0,0 +1,2 @@ +# Временный файл для негативных тестов — не применяется через terraform +# Тесты запускаются вручную с временным переименованием в .tf diff --git a/hello-node/variables.tf b/hello-node/variables.tf new file mode 100644 index 0000000..538fc2b --- /dev/null +++ b/hello-node/variables.tf @@ -0,0 +1,10 @@ +# 2026-03-11 +# variables.tf — входные переменные для hello-node примера. + +# JWT токен облака (nubes). Передаётся через terraform.tfvars (gitignored). +# Из токена провайдер вычисляет namespace: sless-{sha256[:8]} +variable "token" { + description = "JWT токен облака для аутентификации в sless API" + type = string + sensitive = true +} diff --git a/notes-python/code/notes-list/notes_list.py b/notes-python/code/notes-list/notes_list.py new file mode 100644 index 0000000..4e63bf2 --- /dev/null +++ b/notes-python/code/notes-list/notes_list.py @@ -0,0 +1,31 @@ +# 2026-03-09 +# notes_list.py — чтение всех записей из таблицы notes. +# +# Назначение: отдать полный список заметок одним запросом. +# Принимает GET или POST — тело/query параметры игнорируются. +# Возвращает JSON-массив, сортировка: новые записи первые (ORDER BY created_at DESC). +# +# Пример ответа: +# [ +# {"id": 3, "title": "Hello", "body": "World", "created_at": "2026-03-09 ..."}, +# {"id": 1, "title": "First", "body": "Note", "created_at": "2026-03-08 ..."} +# ] +import os +import psycopg2 +import psycopg2.extras + + +def list_notes(event): + dsn = os.environ['PG_DSN'] + conn = psycopg2.connect(dsn) + try: + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + "SELECT id, title, body, created_at::text FROM notes ORDER BY created_at DESC" + ) + rows = cur.fetchall() + return [dict(r) for r in rows] + except Exception as e: + return {'error': str(e)} + finally: + conn.close() diff --git a/notes-python/code/notes-list/requirements.txt b/notes-python/code/notes-list/requirements.txt new file mode 100644 index 0000000..37ec460 --- /dev/null +++ b/notes-python/code/notes-list/requirements.txt @@ -0,0 +1 @@ +psycopg2-binary diff --git a/notes-python/code/notes/notes_crud.py b/notes-python/code/notes/notes_crud.py new file mode 100644 index 0000000..07b2592 --- /dev/null +++ b/notes-python/code/notes/notes_crud.py @@ -0,0 +1,81 @@ +# 2026-03-09 +# notes_crud.py — CRUD роутер для таблицы notes. +# +# Назначение: единая функция, которая обрабатывает все операции с записями. +# Роутинг осуществляется по sub-path URL (event._path), который runtime +# берёт из входящего HTTP-запроса и добавляет в event автоматически. +# +# Доступные маршруты (все POST): +# /fn/default/notes/add?title=...&body=... → создать запись +# /fn/default/notes/update?id=1&title=...&body=... → обновить запись +# /fn/default/notes/delete?id=1 → удалить запись +# +# Параметры берутся из query string (event._query) или из тела запроса (event). +# event._path и event._query добавляет Python runtime (server.py) автоматически. +import os +import psycopg2 +import psycopg2.extras + + +def crud(event): + dsn = os.environ['PG_DSN'] + # sub-path без ведущего слэша: "add", "update", "delete" + action = event.get('_path', '/').strip('/') + q = event.get('_query', {}) + + conn = psycopg2.connect(dsn) + try: + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + + if action == 'add': + title = q.get('title') or event.get('title', '') + body = q.get('body') or event.get('body', '') + if not title: + return {'error': 'title is required'} + cur.execute( + "INSERT INTO notes (title, body) VALUES (%s, %s)" + " RETURNING id, title, body, created_at::text", + (title, body) + ) + row = cur.fetchone() + conn.commit() + return dict(row) + + elif action == 'update': + id_ = q.get('id') or event.get('id') + if not id_: + return {'error': 'id is required'} + title = q.get('title') or event.get('title', '') + body = q.get('body') or event.get('body', '') + cur.execute( + "UPDATE notes SET title=%s, body=%s WHERE id=%s" + " RETURNING id, title, body, created_at::text", + (title, body, int(id_)) + ) + row = cur.fetchone() + conn.commit() + return dict(row) if row else {'error': 'not found'} + + elif action == 'delete': + id_ = q.get('id') or event.get('id') + if not id_: + return {'error': 'id is required'} + cur.execute( + "DELETE FROM notes WHERE id=%s RETURNING id", + (int(id_),) + ) + row = cur.fetchone() + conn.commit() + return {'deleted': row['id']} if row else {'error': 'not found'} + + else: + return { + 'error': f'unknown action: /{action}', + 'hint': 'use /add?title=...&body=..., /update?id=X&title=...&body=..., /delete?id=X' + } + + except Exception as e: + conn.rollback() + return {'error': str(e)} + finally: + conn.close() diff --git a/notes-python/code/notes/requirements.txt b/notes-python/code/notes/requirements.txt new file mode 100644 index 0000000..37ec460 --- /dev/null +++ b/notes-python/code/notes/requirements.txt @@ -0,0 +1 @@ +psycopg2-binary diff --git a/notes-python/code/sql-runner/requirements.txt b/notes-python/code/sql-runner/requirements.txt new file mode 100644 index 0000000..37ec460 --- /dev/null +++ b/notes-python/code/sql-runner/requirements.txt @@ -0,0 +1 @@ +psycopg2-binary diff --git a/notes-python/code/sql-runner/sql_runner.py b/notes-python/code/sql-runner/sql_runner.py new file mode 100644 index 0000000..fae7675 --- /dev/null +++ b/notes-python/code/sql-runner/sql_runner.py @@ -0,0 +1,39 @@ +# 2026-03-09 +# sql_runner.py — универсальный DDL/SQL исполнитель. +# +# Назначение: выполнять произвольные SQL запросы переданные через event. +# Используется ТОЛЬКО через sless_job (init.tf) — HTTP-триггера нет намеренно, +# чтобы никто снаружи не мог выполнить произвольный SQL. +# +# Входящий event: +# { +# "statements": [ +# "CREATE TABLE IF NOT EXISTS ...", +# "CREATE INDEX IF NOT EXISTS ..." +# ] +# } +# +# Все statements выполняются последовательно в одной транзакции. +# Если хотя бы один упал — транзакция откатывается целиком. +import os +import psycopg2 + + +def run_sql(event): + dsn = os.environ['PG_DSN'] + statements = event.get('statements', []) + if not statements: + return {'error': 'no statements provided'} + + conn = psycopg2.connect(dsn) + try: + cur = conn.cursor() + for sql in statements: + cur.execute(sql) + conn.commit() + return {'ok': True, 'executed': len(statements)} + except Exception as e: + conn.rollback() + return {'error': str(e)} + finally: + conn.close() diff --git a/notes-python/init.tf b/notes-python/init.tf new file mode 100644 index 0000000..85cf5fb --- /dev/null +++ b/notes-python/init.tf @@ -0,0 +1,44 @@ +# 2026-03-09 +# init.tf — однократная инициализация схемы БД через sless_job. +# +# Джобы запускаются один раз при terraform apply и ждут завершения. +# Использует функцию sql_runner (без HTTP-триггера) для безопасного DDL. +# +# Порядок выполнения гарантирован через depends_on: +# 1. notes_table_init — создаём таблицу +# 2. notes_index_init — создаём индекс (требует таблицу) +# +# Для повторного запуска (например, после DROP TABLE) — увеличь run_id. +# run_id отслеживается в state: при изменении terraform перезапустит джоб. + +# Джоб создания таблицы notes. +# CREATE TABLE IF NOT EXISTS — безопасно запускать повторно, таблица не пересоздаётся. +resource "sless_job" "notes_table_init" { + name = "notes-create-table" + function = sless_function.sql_runner.name + wait_timeout_sec = 120 + run_id = 1 + + event_json = jsonencode({ + statements = [ + "CREATE TABLE IF NOT EXISTS notes (id serial PRIMARY KEY, title text NOT NULL, body text, created_at timestamp DEFAULT now())" + ] + }) +} + +# Джоб создания индекса для сортировки по дате. +# depends_on гарантирует, что таблица уже создана до создания индекса. +resource "sless_job" "notes_index_init" { + depends_on = [sless_job.notes_table_init] + + name = "notes-create-index" + function = sless_function.sql_runner.name + wait_timeout_sec = 60 + run_id = 1 + + event_json = jsonencode({ + statements = [ + "CREATE INDEX IF NOT EXISTS notes_created_idx ON notes(created_at DESC)" + ] + }) +} diff --git a/notes-python/main.tf b/notes-python/main.tf new file mode 100644 index 0000000..d4c1772 --- /dev/null +++ b/notes-python/main.tf @@ -0,0 +1,27 @@ +# 2026-03-09 +# main.tf — конфигурация terraform и провайдеров. +# +# Все ресурсы вынесены в отдельные .tf файлы по назначению: +# variables.tf — входные переменные (pg_dsn) +# sql-runner.tf — служебная DDL-функция (без HTTP-триггера) +# init.tf — однократная инициализация схемы БД +# notes.tf — CRUD функция + HTTP-триггер +# notes-list.tf — read-only функция + HTTP-триггер +# outputs.tf — URLs развёрнутых эндпоинтов + +terraform { + required_providers { + # Провайдер для управления serverless функциями через sless API + sless = { + source = "terra.k8c.ru/naeel/sless" + version = "~> 0.1.13" + } + } +} + +# sless провайдер подключается к API кластера. +provider "sless" { + endpoint = "https://sless-api.kube5s.ru" + token = var.token + nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1" +} diff --git a/notes-python/notes-list.tf b/notes-python/notes-list.tf new file mode 100644 index 0000000..ced284b --- /dev/null +++ b/notes-python/notes-list.tf @@ -0,0 +1,22 @@ +# 2026-03-09 +# notes-list.tf — read-only эндпоинт: возвращает все заметки, сортировка новые первые. + +resource "sless_function" "notes_list" { + name = "notes-list" + runtime = "python3.11" + entrypoint = "notes_list.list_notes" + memory_mb = 128 + timeout_sec = 30 + + env_vars = { + PG_DSN = var.pg_dsn + } + + source_dir = "${path.module}/code/notes-list" +} + +resource "sless_trigger" "notes_list_http" { + name = "notes-list-http" + type = "http" + function = sless_function.notes_list.name +} diff --git a/notes-python/notes.tf b/notes-python/notes.tf new file mode 100644 index 0000000..7830dc3 --- /dev/null +++ b/notes-python/notes.tf @@ -0,0 +1,27 @@ +# 2026-03-09 +# notes.tf — CRUD функция для управления заметками (CREATE / UPDATE / DELETE). +# +# Маршруты (рекомендуется POST): +# /fn/default/notes/add?title=...&body=... → INSERT +# /fn/default/notes/update?id=1&title=...&body=... → UPDATE +# /fn/default/notes/delete?id=1 → DELETE + +resource "sless_function" "notes_crud" { + name = "notes" + runtime = "python3.11" + entrypoint = "notes_crud.crud" + memory_mb = 128 + timeout_sec = 30 + + env_vars = { + PG_DSN = var.pg_dsn + } + + source_dir = "${path.module}/code/notes" +} + +resource "sless_trigger" "notes_crud_http" { + name = "notes-http" + type = "http" + function = sless_function.notes_crud.name +} diff --git a/notes-python/outputs.tf b/notes-python/outputs.tf new file mode 100644 index 0000000..4e7f6d3 --- /dev/null +++ b/notes-python/outputs.tf @@ -0,0 +1,42 @@ +# 2026-03-09 +# outputs.tf — публичные URL развёрнутых функций. +# +# После terraform apply используй эти URLs для тестирования: +# terraform output notes_url → базовый URL для CRUD +# terraform output notes_list_url → URL для получения всех записей + +# URL CRUD-функции (notes_crud). +# Базовый URL — к нему добавляй sub-path: +# POST $(terraform output -raw notes_url)/add?title=Hello&body=World +# POST $(terraform output -raw notes_url)/update?id=1&title=Updated +# POST $(terraform output -raw notes_url)/delete?id=1 +output "notes_url" { + value = sless_trigger.notes_crud_http.url + description = "CRUD: /add?title=...&body=..., /update?id=X&title=...&body=..., /delete?id=X" +} + +# URL read-only функции (notes_list). +# Принимает GET или POST, параметры игнорирует, возвращает все записи. +output "notes_list_url" { + value = sless_trigger.notes_list_http.url + description = "Список всех записей (GET или POST)" +} + +# Статус init-джобов — показывает результат инициализации БД. +# Если phase="Succeeded" — таблица и индекс созданы успешно. +# Если phase="Failed" — смотри message, исправь и увеличь run_id в init.tf. +output "db_init_table_status" { + value = { + phase = sless_job.notes_table_init.phase + message = sless_job.notes_table_init.message + } + description = "Статус джоба создания таблицы notes" +} + +output "db_init_index_status" { + value = { + phase = sless_job.notes_index_init.phase + message = sless_job.notes_index_init.message + } + description = "Статус джоба создания индекса" +} diff --git a/notes-python/sql-runner.tf b/notes-python/sql-runner.tf new file mode 100644 index 0000000..29be82d --- /dev/null +++ b/notes-python/sql-runner.tf @@ -0,0 +1,20 @@ +# 2026-03-09 +# sql-runner.tf — служебная DDL-функция для инициализации и миграций БД. +# +# ВАЖНО: эта функция не имеет HTTP-триггера — только вызов через sless_job. +# Это сделано намеренно: функция выполняет произвольный SQL, и открывать её +# наружу через HTTP было бы небезопасно. + +resource "sless_function" "sql_runner" { + name = "sql-runner" + runtime = "python3.11" + entrypoint = "sql_runner.run_sql" + memory_mb = 128 + timeout_sec = 30 + + env_vars = { + PG_DSN = var.pg_dsn + } + + source_dir = "${path.module}/code/sql-runner" +} diff --git a/notes-python/variables.tf b/notes-python/variables.tf new file mode 100644 index 0000000..0e0b3bb --- /dev/null +++ b/notes-python/variables.tf @@ -0,0 +1,23 @@ +# 2026-03-09 (обновлён 2026-03-11) +# variables.tf — входные переменные для notes-python примера. +# +# PG_DSN передаётся во все функции через env_vars. +# Хранится как sensitive чтобы не светился в terraform output и логах. +# В продакшне — не хардкоди DSN здесь, используй TF_VAR_pg_dsn или secrets manager. + +# JWT токен облака (nubes). Передаётся через terraform.tfvars (gitignored). +# Из токена провайдер вычисляет namespace: sless-{sha256[:8]} +variable "token" { + description = "JWT токен облака для аутентификации в sless API" + type = string + sensitive = true +} + +# DSN для подключения к PostgreSQL внутри кластера. +# Формат: postgres://user:password@host:port/dbname?sslmode=... +variable "pg_dsn" { + description = "PostgreSQL DSN для подключения к БД внутри кластера" + type = string + default = "postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable" + sensitive = true +} diff --git a/push-sample/Dockerfile b/push-sample/Dockerfile new file mode 100644 index 0000000..faa19a5 --- /dev/null +++ b/push-sample/Dockerfile @@ -0,0 +1,7 @@ +# 2026-03-11 10:00 +# Minimal sample image to push to PearlHarbor registry +# Purpose: небольшой образ для тестирования пуша в реестр + +FROM alpine:3.18 + +CMD ["sh", "-c", "echo Hello from pearlharbor sample image"] diff --git a/push-sample/README.md b/push-sample/README.md new file mode 100644 index 0000000..51043a0 --- /dev/null +++ b/push-sample/README.md @@ -0,0 +1,28 @@ +# Пример для пуша в PearlHarbor + +Файлы: +- [examples/push-sample/Dockerfile](examples/push-sample/Dockerfile) — минимальный образ +- [examples/push-sample/build_and_push.sh](examples/push-sample/build_and_push.sh) — сборка и опциональный пуш + +Как использовать: + +1. Сборка локально (в корне репы): + +```bash +docker build -t sless-sample:local -f examples/push-sample/Dockerfile examples/push-sample +``` + +2. Протестировать скрипт (скрипт не будет пушить без переменной DO_PUSH): + +```bash +cd examples/push-sample +./build_and_push.sh +``` + +3. Для реального пуша установите `DO_PUSH=true`. Скрипт прочитает `secrets/pearlharbor_registry.txt`. + +```bash +DO_PUSH=true ./build_and_push.sh +``` + +Примечание: скрипт использует по умолчанию пользователя `admin`. Для другого пользователя задайте `REGISTRY_USER`. diff --git a/push-sample/build_and_push.sh b/push-sample/build_and_push.sh new file mode 100755 index 0000000..92f563f --- /dev/null +++ b/push-sample/build_and_push.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# 2026-03-11 10:02 +# Скрипт: собирает минимальный образ и, при разрешении, пушит в реестр PearlHarbor +# Требования: `docker` в PATH. Скрипт НЕ будет пушить без DO_PUSH=true. + +set -euo pipefail + +# Получаем значения из файла секретов +SECRETS_FILE="secrets/pearlharbor_registry.txt" +if [ ! -f "$SECRETS_FILE" ]; then + echo "Файл с секретами не найден: $SECRETS_FILE" + exit 1 +fi + +connection_url=$(grep -E '^connection_url=' "$SECRETS_FILE" | cut -d'=' -f2-) +admin_pass=$(grep -E '^admin_pass=' "$SECRETS_FILE" | cut -d'=' -f2-) + +if [ -z "$connection_url" ]; then + echo "Не найден connection_url в $SECRETS_FILE" + exit 1 +fi + +# Убираем протокол и возможный слеш на конце +registry_host=$(echo "$connection_url" | sed -E 's~https?://~~' | sed -E 's~/$~~') + +image_name="$registry_host/sless-sample:latest" + +echo "Registry host: $registry_host" +echo "Image name: $image_name" + +echo "Собираю образ локально..." +docker build -t sless-sample:local -f Dockerfile .. || { + echo "Сборка не удалась"; exit 1 +} + +echo "Готово. Образ: sless-sample:local" + +if [ "${DO_PUSH:-}" != "true" ]; then + echo "DO_PUSH != true — пуш не будет выполнен. Чтобы запушить: DO_PUSH=true ./build_and_push.sh" + exit 0 +fi + +# Если дошли до сюда — выполняем login/push +registry_user=${REGISTRY_USER:-admin} + +echo "Выполняю docker login к $registry_host как '$registry_user'" +echo "$admin_pass" | docker login "$registry_host" -u "$registry_user" --password-stdin + +echo "Тегирую и пушу образ: $image_name" +docker tag sless-sample:local "$image_name" +docker push "$image_name" + +echo "Пуш завершён. Проверьте реестр для образа: $image_name" diff --git a/run_terraform_examples.sh b/run_terraform_examples.sh new file mode 100755 index 0000000..0ea985a --- /dev/null +++ b/run_terraform_examples.sh @@ -0,0 +1,333 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LOG_DIR="$ROOT_DIR/.test-logs" +mkdir -p "$LOG_DIR" + +EXAMPLES=( + "hello-node" + "simple-node" + "simple-python" + "notes-python" +) + +declare -A CHECK_METHOD=( + [hello-node]="POST" + [simple-node]="GET" + [simple-python]="GET" + [notes-python]="GET" +) + +declare -A CHECK_URL=( + [hello-node]="https://sless-api.kube5s.ru/fn/default/hello-http" + [simple-node]="https://sless-api.kube5s.ru/fn/default/simple-node-time-display" + [simple-python]="https://sless-api.kube5s.ru/fn/default/simple-py-time-display" + [notes-python]="https://sless-api.kube5s.ru/fn/default/notes-list" +) + +declare -A CHECK_DATA=( + [hello-node]='{"name":"Smoke"}' + [simple-node]='' + [simple-python]='' + [notes-python]='' +) + +declare -a PREEXISTING_EXAMPLES=() +LAST_LOG_FILE="" +CURRENT_EXAMPLE="" +CURRENT_STEP="" +LAST_ERROR_SUMMARY="" + +fail_run() { + local example="$1" + local step="$2" + local details="$3" + + echo + echo "ERROR SUMMARY" + echo "example: $example" + echo "step: $step" + echo "reason: $details" + + if [ -n "$LAST_LOG_FILE" ] && [ -f "$LAST_LOG_FILE" ]; then + echo "log: $LAST_LOG_FILE" + echo "last log lines:" + tail -n 20 "$LAST_LOG_FILE" + fi + + exit 1 +} + +run_step() { + local example="$1" + local step="$2" + shift 2 + + CURRENT_EXAMPLE="$example" + CURRENT_STEP="$step" + LAST_ERROR_SUMMARY="" + + if ! "$@"; then + local details="$LAST_ERROR_SUMMARY" + if [ -z "$details" ]; then + details="step failed without explicit summary" + fi + fail_run "$example" "$step" "$details" + fi +} + +restore_any_backups() { + local backup + while IFS= read -r backup; do + [ -n "$backup" ] || continue + if [ -f "$backup" ]; then + mv "$backup" "${backup%.copilot.bak}" + fi + done < <(find "$ROOT_DIR" -name '*.copilot.bak' | sort) +} + +trap restore_any_backups EXIT + +clean_local_artifacts() { + local example="$1" + rm -rf \ + "$ROOT_DIR/$example/.terraform" \ + "$ROOT_DIR/$example/.terraform.lock.hcl" \ + "$ROOT_DIR/$example/terraform.tfstate" \ + "$ROOT_DIR/$example/terraform.tfstate.backup" \ + "$ROOT_DIR/$example"/terraform.tfstate.*.backup \ + "$ROOT_DIR/$example/dist" +} + +retry_tf() { + local example="$1" + local label="$2" + shift 2 + + local attempt=1 + while [ "$attempt" -le 3 ]; do + LAST_LOG_FILE="$LOG_DIR/${example//\//_}-${label// /_}-${attempt}.log" + echo "==> [$example] $label (attempt $attempt/3)" + + ( + cd "$ROOT_DIR/$example" + "$@" + ) 2>&1 | tee "$LAST_LOG_FILE" + + local status=${PIPESTATUS[0]} + if [ "$status" -eq 0 ]; then + return 0 + fi + + if grep -Eiq 'Unauthorized|401|403' "$LAST_LOG_FILE"; then + LAST_ERROR_SUMMARY="authorization error during $label" + echo "[$example] authorization error during $label" + return 41 + fi + + if grep -Eiq 'TLS handshake timeout|tls:.*timeout|i/o timeout|Client\.Timeout exceeded while awaiting headers|context deadline exceeded|unexpected EOF' "$LAST_LOG_FILE" && [ "$attempt" -lt 3 ]; then + attempt=$((attempt + 1)) + sleep 2 + continue + fi + + if grep -Eiq 'TLS handshake timeout|tls:.*timeout|i/o timeout|Client\.Timeout exceeded while awaiting headers|context deadline exceeded|unexpected EOF' "$LAST_LOG_FILE"; then + LAST_ERROR_SUMMARY="network/provider download failure during $label after retries" + else + LAST_ERROR_SUMMARY="terraform command failed during $label with exit code $status" + fi + + return "$status" + done + + LAST_ERROR_SUMMARY="terraform command failed during $label after exhausting retries" + return 1 +} + +record_preexisting_if_needed() { + local example="$1" + if grep -Fq 'No changes. Your infrastructure matches the configuration.' "$LAST_LOG_FILE"; then + PREEXISTING_EXAMPLES+=("$example") + echo "[$example] detected preexisting remote resources on clean apply" + fi +} + +probe_endpoint() { + local example="$1" + local body_file="$LOG_DIR/${example//\//_}-endpoint-body.txt" + local status_file="$LOG_DIR/${example//\//_}-endpoint-status.txt" + local method="${CHECK_METHOD[$example]}" + local url="${CHECK_URL[$example]}" + local data="${CHECK_DATA[$example]}" + + if [ "$method" = "POST" ]; then + curl -sS -X POST -H 'Content-Type: application/json' -d "$data" -o "$body_file" -w '%{http_code}' "$url" > "$status_file" + else + curl -sS -o "$body_file" -w '%{http_code}' "$url" > "$status_file" + fi +} + +assert_live_endpoint() { + local example="$1" + probe_endpoint "$example" + + local body_file="$LOG_DIR/${example//\//_}-endpoint-body.txt" + local status + status="$(cat "$LOG_DIR/${example//\//_}-endpoint-status.txt")" + + if [ "$status" != "200" ]; then + LAST_ERROR_SUMMARY="live endpoint check failed with HTTP $status" + echo "[$example] live endpoint check failed with HTTP $status" + cat "$body_file" + return 1 + fi + + if grep -Fq 'function unreachable' "$body_file"; then + LAST_ERROR_SUMMARY="live endpoint returned function unreachable" + echo "[$example] live endpoint check returned unreachable function" + cat "$body_file" + return 1 + fi +} + +assert_destroyed_endpoint() { + local example="$1" + probe_endpoint "$example" + + local body_file="$LOG_DIR/${example//\//_}-endpoint-body.txt" + local status + status="$(cat "$LOG_DIR/${example//\//_}-endpoint-status.txt")" + + if [ "$status" = "404" ] || [ "$status" = "000" ]; then + return 0 + fi + + if grep -Eiq 'not found|404 page not found' "$body_file"; then + return 0 + fi + + if [ "$status" = "502" ] && grep -Fq 'function unreachable' "$body_file"; then + LAST_ERROR_SUMMARY="route cleanup bug: public endpoint still exists but backend is already gone (HTTP 502 function unreachable)" + echo "[$example] route still exists after destroy, but backend is already gone (HTTP 502 function unreachable)" + cat "$body_file" + return 1 + fi + + LAST_ERROR_SUMMARY="endpoint still responds after destroy with HTTP $status" + echo "[$example] endpoint still responds after destroy with HTTP $status" + cat "$body_file" + return 1 +} + +wait_for_destroyed_endpoint() { + local example="$1" + local attempts=24 + local sleep_sec=5 + local try=1 + + while [ "$try" -le "$attempts" ]; do + if assert_destroyed_endpoint "$example"; then + echo "[$example] endpoint disappeared after destroy" + return 0 + fi + + echo "[$example] endpoint still present after destroy, waiting (${try}/${attempts})" + try=$((try + 1)) + sleep "$sleep_sec" + done + + echo "[$example] endpoint did not disappear after destroy within $((attempts * sleep_sec))s" + if [ -z "$LAST_ERROR_SUMMARY" ]; then + LAST_ERROR_SUMMARY="endpoint remained reachable for more than $((attempts * sleep_sec))s after destroy" + else + LAST_ERROR_SUMMARY="$LAST_ERROR_SUMMARY; endpoint was still published after $((attempts * sleep_sec))s" + fi + return 1 +} + +backup_and_modify() { + local example="$1" + case "$example" in + hello-node) + cp "$ROOT_DIR/$example/http.tf" "$ROOT_DIR/$example/http.tf.copilot.bak" + perl -0pi -e 's/enabled\s+=\s+true/enabled = false/' "$ROOT_DIR/$example/http.tf" + ;; + simple-node) + cp "$ROOT_DIR/$example/time-display.tf" "$ROOT_DIR/$example/time-display.tf.copilot.bak" + perl -0pi -e 's/memory_mb\s+=\s+64/memory_mb = 96/' "$ROOT_DIR/$example/time-display.tf" + ;; + simple-python) + cp "$ROOT_DIR/$example/time-display.tf" "$ROOT_DIR/$example/time-display.tf.copilot.bak" + perl -0pi -e 's/memory_mb\s+=\s+64/memory_mb = 96/' "$ROOT_DIR/$example/time-display.tf" + ;; + notes-python) + cp "$ROOT_DIR/$example/notes-list.tf" "$ROOT_DIR/$example/notes-list.tf.copilot.bak" + perl -0pi -e 's/memory_mb\s+=\s+128/memory_mb = 160/' "$ROOT_DIR/$example/notes-list.tf" + ;; + esac +} + +restore_modified_files() { + local example="$1" + case "$example" in + hello-node) + mv "$ROOT_DIR/$example/http.tf.copilot.bak" "$ROOT_DIR/$example/http.tf" + ;; + simple-node) + mv "$ROOT_DIR/$example/time-display.tf.copilot.bak" "$ROOT_DIR/$example/time-display.tf" + ;; + simple-python) + mv "$ROOT_DIR/$example/time-display.tf.copilot.bak" "$ROOT_DIR/$example/time-display.tf" + ;; + notes-python) + mv "$ROOT_DIR/$example/notes-list.tf.copilot.bak" "$ROOT_DIR/$example/notes-list.tf" + ;; + esac +} + +run_example() { + local example="$1" + + echo + echo "==== $example ====" + + clean_local_artifacts "$example" + run_step "$example" "terraform init" retry_tf "$example" "terraform init" terraform init -input=false -no-color + run_step "$example" "terraform apply clean" retry_tf "$example" "terraform apply clean" terraform apply -auto-approve -input=false -no-color + record_preexisting_if_needed "$example" + run_step "$example" "endpoint check after clean apply" assert_live_endpoint "$example" + + run_step "$example" "terraform destroy clean" retry_tf "$example" "terraform destroy clean" terraform destroy -auto-approve -input=false -no-color + run_step "$example" "endpoint cleanup after clean destroy" wait_for_destroyed_endpoint "$example" + + run_step "$example" "terraform apply second" retry_tf "$example" "terraform apply second" terraform apply -auto-approve -input=false -no-color + run_step "$example" "endpoint check after second apply" assert_live_endpoint "$example" + + backup_and_modify "$example" + run_step "$example" "terraform apply modified" retry_tf "$example" "terraform apply modified" terraform apply -auto-approve -input=false -no-color + restore_modified_files "$example" + + run_step "$example" "terraform destroy final" retry_tf "$example" "terraform destroy final" terraform destroy -auto-approve -input=false -no-color + run_step "$example" "endpoint cleanup after final destroy" wait_for_destroyed_endpoint "$example" + clean_local_artifacts "$example" +} + +main() { + local example + for example in "${EXAMPLES[@]}"; do + run_example "$example" + done + + if [ "${#PREEXISTING_EXAMPLES[@]}" -gt 0 ]; then + echo + echo "Preexisting remote resources were detected on first apply for: ${PREEXISTING_EXAMPLES[*]}" + exit 2 + fi + + echo + echo "All Terraform example lifecycles completed successfully." +} + +main "$@" \ No newline at end of file diff --git a/simple-node/code/time_display/time_display.js b/simple-node/code/time_display/time_display.js new file mode 100644 index 0000000..a711b3e --- /dev/null +++ b/simple-node/code/time_display/time_display.js @@ -0,0 +1,20 @@ +// Создано: 2026-03-09 +// time_display.js — HTTP-функция (постоянный Deployment + Trigger). +// Читает env JOB_TIME, которую terraform передаёт из sless_job.run_getter.message. +// Демонстрирует цепочку: Job вычисляет данные → Function использует их через env. + +exports.showTime = function(event) { + // JOB_TIME устанавливается terraform из статуса джоба (JSON строка) + const jobTimeRaw = process.env.JOB_TIME || '{}'; + let jobTime; + try { + const parsed = JSON.parse(jobTimeRaw); + jobTime = parsed.time || jobTimeRaw; + } catch (e) { + jobTime = jobTimeRaw; + } + return { + message: `Сервис запустился в: ${jobTime}`, + path: event._path || '/', + }; +}; diff --git a/simple-node/code/time_getter/time_getter.js b/simple-node/code/time_getter/time_getter.js new file mode 100644 index 0000000..a750c17 --- /dev/null +++ b/simple-node/code/time_getter/time_getter.js @@ -0,0 +1,10 @@ +// Создано: 2026-03-09 +// time_getter.js — функция запускается как Job (одноразово). +// Возвращает JSON со временем запуска. Оператор v0.1.16 захватывает stdout +// и записывает его в sless_job.run_getter.message — оттуда terraform передаёт +// значение в sless_function.display через env_var JOB_TIME. + +exports.getTime = function(event) { + // Возвращаем время в ISO 8601 UTC — без зависимостей, только stdlib + return { time: new Date().toISOString() }; +}; diff --git a/simple-node/main.tf b/simple-node/main.tf new file mode 100644 index 0000000..2e1927a --- /dev/null +++ b/simple-node/main.tf @@ -0,0 +1,31 @@ +# Создано: 2026-03-09 +# main.tf — пример: запустить один раз скрипт при деплое и передать его результат в функцию. +# То же самое что simple-python, но на Node.js 20. +# +# Как это работает: +# 1. При «terraform apply» запускается скрипт-джоб (time_getter) +# 2. Скрипт возвращает JSON с текущим временем +# 3. Terraform подхватывает этот JSON и передаёт в переменную окружения HTTP-функции (time_display) +# 4. Функция отдаёт время при каждом запросе +# +# Зачем такое нужно: +# Если данные нужны функции, но считаются один раз при деплое — +# напишите логику в джоб, а результат передайте через env_vars. +# Например: получить токен, версию схемы БД, время деплоя и т.д. +# +# namespace захардкодирован внутри провайдера, здесь ничего указывать. + +terraform { + required_providers { + sless = { + source = "terra.k8c.ru/naeel/sless" + version = "~> 0.1.13" + } + } +} + +provider "sless" { + endpoint = "https://sless-api.kube5s.ru" + token = var.token + nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1" +} diff --git a/simple-node/outputs.tf b/simple-node/outputs.tf new file mode 100644 index 0000000..4d59ef6 --- /dev/null +++ b/simple-node/outputs.tf @@ -0,0 +1,14 @@ +# Создано: 2026-03-09 +# outputs.tf — что выводит terraform после apply. + +# Адрес вашей функции — откройте в браузере или вставьте в curl +output "display_url" { + description = "URL функции time_display" + value = sless_trigger.display_http.url +} + +# Что вернул скрипт-джоб — именно это передано в функцию как JOB_TIME +output "job_result" { + description = "Результат выполнения скрипта time_getter" + value = sless_job.run_getter.message +} diff --git a/simple-node/time-display.tf b/simple-node/time-display.tf new file mode 100644 index 0000000..7d29bfe --- /dev/null +++ b/simple-node/time-display.tf @@ -0,0 +1,28 @@ +# Создано: 2026-03-09 / Изменено: 2026-03-09 +# time-display.tf — HTTP-функция, доступная по URL после apply. +# Получает результат джоба (из time-getter.tf) через переменную окружения JOB_TIME. + +# HTTP-функция — отвечает на запросы по URL из outputs.tf +resource "sless_function" "time_display" { + name = "simple-node-time-display" # уникальное имя в namespace + runtime = "nodejs20" + entrypoint = "time_display.showTime" # файл.функция в code/time_display/ + memory_mb = 64 + + # Передаём результат джоба в функцию через переменную окружения. + # В коде функции: process.env.JOB_TIME + env_vars = { + JOB_TIME = sless_job.run_getter.message + } + + source_dir = "${path.module}/code/time_display" + + depends_on = [sless_job.run_getter] # ждём завершения джоба перед деплоем функции +} + +# Публикуем функцию по HTTP — URL будет в outputs.tf +resource "sless_trigger" "display_http" { + name = "simple-node-display-http" + type = "http" + function = sless_function.time_display.name +} diff --git a/simple-node/time-getter.tf b/simple-node/time-getter.tf new file mode 100644 index 0000000..8783e1b --- /dev/null +++ b/simple-node/time-getter.tf @@ -0,0 +1,27 @@ +# Создано: 2026-03-09 / Изменено: 2026-03-09 +# time-getter.tf — скрипт который запускается ОДИН РАЗ при terraform apply. +# После запуска его результат доступен через: sless_job.run_getter.message +# Смотри time-display.tf — там этот результат передаётся в функцию. + +# Функция для скрипта — без HTTP-триггера, вызывается только через джоб ниже +resource "sless_function" "time_getter" { + name = "simple-node-time-getter" # уникальное имя в namespace + runtime = "nodejs20" + entrypoint = "time_getter.getTime" # файл.функция в code/time_getter/ + memory_mb = 96 + + source_dir = "${path.module}/code/time_getter" +} + +# Джоб — запускает функцию time_getter один раз прямо при apply. +# run_id = 1 означает «запустить». Если увеличить (2, 3...) — запустится снова. +# После завершения: sless_job.run_getter.message = то что вернула функция +resource "sless_job" "run_getter" { + name = "simple-node-getter-run" + function = sless_function.time_getter.name + run_id = 1 + wait_timeout_sec = 120 # сколько секунд ждать завершения скрипта + event_json = "{}" # входные данные для скрипта (пусто — данные не нужны) + + depends_on = [sless_function.time_getter] +} diff --git a/simple-node/variables.tf b/simple-node/variables.tf new file mode 100644 index 0000000..2057582 --- /dev/null +++ b/simple-node/variables.tf @@ -0,0 +1,10 @@ +# 2026-03-11 +# variables.tf — входные переменные для simple-node примера. + +# JWT токен облака (nubes). Передаётся через terraform.tfvars (gitignored). +# Из токена провайдер вычисляет namespace: sless-{sha256[:8]} +variable "token" { + description = "JWT токен облака для аутентификации в sless API" + type = string + sensitive = true +} diff --git a/simple-python/code/time_display/time_display.py b/simple-python/code/time_display/time_display.py new file mode 100644 index 0000000..789cd44 --- /dev/null +++ b/simple-python/code/time_display/time_display.py @@ -0,0 +1,20 @@ +# Создано: 2026-03-09 +# time_display.py — HTTP-функция (постоянный Deployment + Trigger). +# Читает env JOB_TIME, которую terraform передаёт из sless_job.run_getter.message. +# Демонстрирует цепочку: Job вычисляет данные → Function использует их через env. + +import json +import os + + +def show_time(event): + # JOB_TIME устанавливается terraform из статуса джоба (JSON строка) + job_time_raw = os.environ.get("JOB_TIME", "{}") + try: + job_time = json.loads(job_time_raw).get("time", job_time_raw) + except (json.JSONDecodeError, AttributeError): + job_time = job_time_raw + return { + "message": f"Сервис запустился в: {job_time}", + "path": event.get("_path", "/"), + } diff --git a/simple-python/code/time_getter/time_getter.py b/simple-python/code/time_getter/time_getter.py new file mode 100644 index 0000000..05c47f6 --- /dev/null +++ b/simple-python/code/time_getter/time_getter.py @@ -0,0 +1,18 @@ +# Создано: 2026-03-09 +# time_getter.py — функция запускается как Job (одноразово). +# Возвращает JSON со временем запуска. Оператор v0.1.16 захватывает stdout +# и записывает его в sless_job.run_getter.message — оттуда terraform передаёт +# значение в sless_function.display через env_var JOB_TIME. + +import json +from datetime import datetime, timezone + + +def get_time(event): + # Возвращаем время в ISO 8601 UTC — без зависимостей, только stdlib + return {"time": datetime.now(timezone.utc).isoformat()} + + +if __name__ == "__main__": + # Для локального тестирования без оператора + print(json.dumps(get_time({}))) diff --git a/simple-python/main.tf b/simple-python/main.tf new file mode 100644 index 0000000..d0d3d86 --- /dev/null +++ b/simple-python/main.tf @@ -0,0 +1,30 @@ +# Создано: 2026-03-09 +# main.tf — пример: запустить один раз скрипт при деплое и передать его результат в функцию. +# +# Как это работает: +# 1. При «terraform apply» запускается скрипт-джоб (time_getter) +# 2. Скрипт возвращает JSON с текущим временем +# 3. Terraform подхватывает этот JSON и передаёт в переменную окружения HTTP-функции (time_display) +# 4. Функция отдаёт время при каждом запросе +# +# Зачем такое нужно: +# Если данные нужны функции, но считаются один раз при деплое — +# напишите логику в джоб, а результат передайте через env_vars. +# Например: получить токен, версию схемы БД, время деплоя и т.д. +# +# namespace захардкодирован внутри провайдера, здесь ничего указывать. + +terraform { + required_providers { + sless = { + source = "terra.k8c.ru/naeel/sless" + version = "~> 0.1.13" + } + } +} + +provider "sless" { + endpoint = "https://sless-api.kube5s.ru" + token = var.token + nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1" +} diff --git a/simple-python/outputs.tf b/simple-python/outputs.tf new file mode 100644 index 0000000..4d59ef6 --- /dev/null +++ b/simple-python/outputs.tf @@ -0,0 +1,14 @@ +# Создано: 2026-03-09 +# outputs.tf — что выводит terraform после apply. + +# Адрес вашей функции — откройте в браузере или вставьте в curl +output "display_url" { + description = "URL функции time_display" + value = sless_trigger.display_http.url +} + +# Что вернул скрипт-джоб — именно это передано в функцию как JOB_TIME +output "job_result" { + description = "Результат выполнения скрипта time_getter" + value = sless_job.run_getter.message +} diff --git a/simple-python/time-display.tf b/simple-python/time-display.tf new file mode 100644 index 0000000..c5db376 --- /dev/null +++ b/simple-python/time-display.tf @@ -0,0 +1,28 @@ +# Создано: 2026-03-09 / Изменено: 2026-03-09 +# time-display.tf — HTTP-функция, доступная по URL после apply. +# Получает результат джоба (из time-getter.tf) через переменную окружения JOB_TIME. + +# HTTP-функция — отвечает на запросы по URL из outputs.tf +resource "sless_function" "time_display" { + name = "simple-py-time-display" # уникальное имя в namespace + runtime = "python3.11" + entrypoint = "time_display.show_time" # файл.функция в code/time_display/ + memory_mb = 64 + + # Передаём результат джоба в функцию через переменную окружения. + # В коде функции: os.environ.get("JOB_TIME") + env_vars = { + JOB_TIME = sless_job.run_getter.message + } + + source_dir = "${path.module}/code/time_display" + + depends_on = [sless_job.run_getter] # ждём завершения джоба перед деплоем функции +} + +# Публикуем функцию по HTTP — URL будет в outputs.tf +resource "sless_trigger" "display_http" { + name = "simple-py-display-http" + type = "http" + function = sless_function.time_display.name +} diff --git a/simple-python/time-getter.tf b/simple-python/time-getter.tf new file mode 100644 index 0000000..d871b3c --- /dev/null +++ b/simple-python/time-getter.tf @@ -0,0 +1,27 @@ +# Создано: 2026-03-09 / Изменено: 2026-03-09 +# time-getter.tf — скрипт который запускается ОДИН РАЗ при terraform apply. +# После запуска его результат доступен через: sless_job.run_getter.message +# Смотри time-display.tf — там этот результат передаётся в функцию. + +# Функция для скрипта — без HTTP-триггера, вызывается только через джоб ниже +resource "sless_function" "time_getter" { + name = "simple-py-time-getter" # уникальное имя в namespace + runtime = "python3.11" + entrypoint = "time_getter.get_time" # файл.функция в code/time_getter/ + memory_mb = 96 + + source_dir = "${path.module}/code/time_getter" +} + +# Джоб — запускает функцию time_getter один раз прямо при apply. +# run_id = 1 означает «запустить». Если увеличить (2, 3...) — запустится снова. +# После завершения: sless_job.run_getter.message = то что вернула функция +resource "sless_job" "run_getter" { + name = "simple-py-getter-run" + function = sless_function.time_getter.name + run_id = 1 + wait_timeout_sec = 120 # сколько секунд ждать завершения скрипта + event_json = "{}" # входные данные для скрипта (пусто — данные не нужны) + + depends_on = [sless_function.time_getter] +} diff --git a/simple-python/variables.tf b/simple-python/variables.tf new file mode 100644 index 0000000..dac71bd --- /dev/null +++ b/simple-python/variables.tf @@ -0,0 +1,10 @@ +# 2026-03-11 +# variables.tf — входные переменные для simple-python примера. + +# JWT токен облака (nubes). Передаётся через terraform.tfvars (gitignored). +# Из токена провайдер вычисляет namespace: sless-{sha256[:8]} +variable "token" { + description = "JWT токен облака для аутентификации в sless API" + type = string + sensitive = true +}