From b4a72850e9077ac7bba5f1b8b6ab9ea3ab346af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Wed, 11 Mar 2026 17:22:08 +0400 Subject: [PATCH] w golang --- .gitignore | 39 ++++ DESTROY_ROUTE_CLEANUP_BUG.md | 143 ------------ README.md | 180 +++++++++------ hello-go/code/greeting.go | 29 +++ hello-go/http.tf | 23 ++ hello-go/job.tf | 28 +++ hello-go/main.tf | 17 ++ hello-go/terraform.tfvars | 1 + hello-go/variables.tf | 10 + pg-list-python/code/catalog.py | 47 ++++ pg-list-python/code/requirements.txt | 1 + pg-list-python/function.tf | 29 +++ pg-list-python/main.tf | 17 ++ pg-list-python/terraform.tfvars | 1 + pg-list-python/variables.tf | 14 ++ push-sample/Dockerfile | 7 - push-sample/README.md | 28 --- push-sample/build_and_push.sh | 53 ----- run_terraform_examples.sh | 333 --------------------------- 19 files changed, 360 insertions(+), 640 deletions(-) create mode 100644 .gitignore delete mode 100644 DESTROY_ROUTE_CLEANUP_BUG.md create mode 100644 hello-go/code/greeting.go create mode 100644 hello-go/http.tf create mode 100644 hello-go/job.tf create mode 100644 hello-go/main.tf create mode 100644 hello-go/terraform.tfvars create mode 100644 hello-go/variables.tf create mode 100644 pg-list-python/code/catalog.py create mode 100644 pg-list-python/code/requirements.txt create mode 100644 pg-list-python/function.tf create mode 100644 pg-list-python/main.tf create mode 100644 pg-list-python/terraform.tfvars create mode 100644 pg-list-python/variables.tf delete mode 100644 push-sample/Dockerfile delete mode 100644 push-sample/README.md delete mode 100755 push-sample/build_and_push.sh delete mode 100755 run_terraform_examples.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..138cb80 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Created: 2026-03-11 +# Purpose: ignore generated artifacts for the `examples` repository + +# Terraform +.terraform/ +*.tfstate +*.tfstate.* +.terraform.lock.hcl +crash.log + +# Terraform plans / backups +*.tfplan +*.backup +*.bak + +# Provider plugins / caches +.terraform.d/ + +# Archives and build artifacts +*.zip +dist/ +build/ + +# Node / Python +node_modules/ +__pycache__/ +*.pyc +venv/ +.venv/ + +# Editor / OS files +.DS_Store +*.swp +*.swo + +# Environment files +.env +*.local +*.log diff --git a/DESTROY_ROUTE_CLEANUP_BUG.md b/DESTROY_ROUTE_CLEANUP_BUG.md deleted file mode 100644 index 4dba243..0000000 --- a/DESTROY_ROUTE_CLEANUP_BUG.md +++ /dev/null @@ -1,143 +0,0 @@ -# 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 index afe6564..30d3c0d 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,119 @@ -# Примеры sless +# Примеры использования sless -## Что такое sless +## Обзор платформы -**sless** — платформа для запуска serverless-функций в Kubernetes-кластере. +**sless** — система управления serverless-функциями на базе Kubernetes. Разработчик загружает код функции, платформа собирает из него Docker-образ, разворачивает его в кластере и предоставляет HTTP-эндпоинт для вызова. Всё описывается декларативно через Terraform. -Код на Python или Node.js загружается в платформу, которая собирает Docker-образ, деплоит его в кластер и публикует HTTP-эндпоинт. Всё управляется через Terraform. +### Основные ресурсы провайдера -### Ресурсы - -| Ресурс | Что делает | +| Ресурс | Назначение | |---|---| -| `sless_function` | Загружает код и собирает Docker-образ. Сама по себе не принимает запросы — нужен триггер или джоб | -| `sless_trigger` | Публикует функцию — либо как HTTP-эндпоинт, либо по расписанию (cron) | -| `sless_job` | Запускает функцию один раз (например, для инициализации БД) и ждёт результата | +| `sless_function` | Описывает функцию: язык, точку входа, лимиты, переменные окружения. При создании загружает код и запускает его сборку в образ. Сама по себе недоступна снаружи — нужен триггер или задание. | +| `sless_trigger` | Публикует функцию: тип `http` создаёт публичный URL, тип `cron` — запуск по расписанию. | +| `sless_job` | Запускает функцию однократно и ожидает завершения. Используется для одноразовых операций: инициализация БД, миграции, пакетная обработка. | -**Типичный сценарий:** `sless_function` с кодом + `sless_trigger` с `type = "http"` → публичный URL вида `https://sless-api.kube5s.ru/fn/default/имя-функции`. +Стандартная связка для HTTP API: `sless_function` + `sless_trigger` с `type = "http"` — в результате функция доступна по URL вида `https://sless-api.kube5s.ru/fn//<имя-функции>`. --- -Примеры показывают различные сценарии использования serverless функций через Terraform провайдер `terra.k8c.ru/naeel/sless`. - ## Требования - Terraform >= 1.0 +- JWT-токен для аутентификации в sless API - Доступ к `https://sless-api.kube5s.ru` -## Провайдер +## Конфигурация провайдера -Во всех примерах `main.tf` содержит: +Во всех примерах файл `main.tf` содержит блок провайдера. Токен передаётся через переменную, значение которой задаётся в `terraform.tfvars`: ```hcl provider "sless" { - endpoint = "https://sless-api.kube5s.ru" - token = "dev-token-change-me" + endpoint = "https://sless-api.kube5s.ru" + token = var.token + nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1" } ``` +Namespace функций вычисляется автоматически из JWT-токена: `sless-{sha256[:8]}`. + --- ## Примеры -### `simple-python` — джоб передаёт результат в HTTP-функцию (Python) +### `hello-node` — минимальный пример на Node.js -При `apply` запускается джоб, его вывод передаётся в HTTP-функцию через `env_vars`. +Две независимые функции: HTTP-функция, возвращающая приветствие, и одноразовое задание, суммирующее набор чисел. Хорошая отправная точка для знакомства с платформой. + +```bash +cd hello-node +terraform init +terraform apply -auto-approve + +# Вызов HTTP-функции с передачей имени: +curl -s -X POST https://sless-api.kube5s.ru/fn//hello-http \ + -H 'Content-Type: application/json' -d '{"name":"World"}' + +# Результат задания: +terraform output job_message +``` + +--- + +### `hello-go` — минимальный пример на Go 1.23 + +Аналог `hello-node`, но на Go. Демонстрирует поддержку Go-рантайма: HTTP-функция и одноразовое задание. Код пользователя оформляется как пакет `handler` с функцией `Handle(event)`. + +```bash +cd hello-go +terraform init +terraform apply -auto-approve + +terraform output job_message +terraform output trigger_url +``` + +--- + +### `pg-list-python` — выборка данных из PostgreSQL (Python) + +Минимальный пример работы с базой данных: одна HTTP-функция читает список записей из таблицы PostgreSQL и возвращает их в JSON. Таблица с тестовыми данными создаётся автоматически при первом вызове. Нет заданий, нет инициализации — только функция и триггер. + +**Переменные:** + +| Переменная | Описание | Значение по умолчанию | +|---|---|---| +| `pg_dsn` | Строка подключения к PostgreSQL | `postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable` | + +```bash +cd pg-list-python +terraform init +terraform apply -auto-approve + +# URL функции выводится после применения: +terraform output catalog_url + +# Запрос к функции: +curl -s $(terraform output -raw catalog_url) +``` + +--- + +### `simple-python` — одноразовое задание передаёт данные в HTTP-функцию (Python) + +При `apply` выполняется задание, которое фиксирует текущее время. Результат передаётся в HTTP-функцию через переменные окружения и отображается при каждом запросе. ```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 +curl -s https://sless-api.kube5s.ru/fn//simple-py-time-display ``` --- -### `simple-node` — то же самое, но на Node.js 20 +### `simple-node` — то же самое на Node.js 20 ```bash cd simple-node @@ -66,95 +121,68 @@ terraform init terraform apply -auto-approve terraform output job_result -curl -s https://sless-api.kube5s.ru/fn/default/simple-node-time-display +curl -s https://sless-api.kube5s.ru/fn//simple-node-time-display ``` --- -### `hello-node` — минимальный пример на Node.js +### `notes-python` — CRUD API на Python с PostgreSQL -Две независимые функции: 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 функция для списка записей. +Полноценное приложение: инициализация схемы базы данных через задания, CRUD-функция для работы с записями, отдельная функция для получения списка. **Переменные:** -| Переменная | Описание | Дефолт | +| Переменная | Описание | Значение по умолчанию | |---|---|---| -| `pg_dsn` | DSN для подключения к PostgreSQL | `postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable` | +| `pg_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 -X POST "$(terraform output -raw notes_url)/add?title=Hello&body=World" -# Список записей: -curl -s https://sless-api.kube5s.ru/fn/default/notes-list +# Получить список записей: +curl -s $(terraform output -raw notes_list_url) -# Обновить (id из предыдущего ответа): -curl -s -X POST "https://sless-api.kube5s.ru/fn/default/notes/update?id=1&title=Updated&body=New+body" +# Обновить запись (id из предыдущего ответа): +curl -s -X POST "$(terraform output -raw notes_url)/update?id=1&title=Updated&body=New+body" -# Удалить: -curl -s -X POST "https://sless-api.kube5s.ru/fn/default/notes/delete?id=1" +# Удалить запись: +curl -s -X POST "$(terraform output -raw notes_url)/delete?id=1" ``` --- -## Общие команды +## Полезные команды ```bash -# Посмотреть текущее состояние ресурсов: +# Посмотреть текущее состояние задеплоенных ресурсов: terraform show -# Пересоздать конкретный ресурс: -terraform apply -replace=sless_function.имя -auto-approve +# Принудительно пересобрать функцию (например, после изменения кода): +terraform apply -replace=sless_function.<имя> -auto-approve -# Повторно запустить джоб — увеличить run_id в .tf файле, затем: +# Повторно запустить задание: увеличить значение run_id в .tf-файле, затем: terraform apply -auto-approve # Удалить все ресурсы примера: terraform destroy -auto-approve ``` -## Структура каждого примера +## Структура примера ``` -пример/ -├── main.tf — провайдер -├── *.tf — ресурсы (функции, триггеры, джобы) -├── outputs.tf — URLs и статусы после apply -├── variables.tf — входные переменные (если есть) -└── code/ — исходный код функций +<пример>/ +├── main.tf — конфигурация провайдера +├── *.tf — ресурсы: функции, триггеры, задания +├── variables.tf — входные переменные +├── terraform.tfvars — значения переменных (не коммитится в git) +└── code/ — исходный код функций ``` diff --git a/hello-go/code/greeting.go b/hello-go/code/greeting.go new file mode 100644 index 0000000..a32106e --- /dev/null +++ b/hello-go/code/greeting.go @@ -0,0 +1,29 @@ +// Изменено: 2026-03-11 +// greeting.go — пример Go функции: возвращает приветствие. +// +// ТРЕБОВАНИЕ РАНТАЙМА: пакет должен называться handler, точка входа — Handle. +// Вся бизнес-логика — в отдельных функциях с нормальными именами. + +package handler + +import "fmt" + +// buildGreeting — формирует текст приветствия для указанного имени гостя. +func buildGreeting(guestName string) string { + return fmt.Sprintf("Hello, %s! (Go 1.23)", guestName) +} + +// Handle — точка входа, вызывается рантаймом на каждый запрос/событие. +// Не переименовывать: это жёсткий контракт рантайма (server.go вызывает handler.Handle). +func Handle(event map[string]interface{}) interface{} { + guestName, ok := event["name"].(string) + if !ok || guestName == "" { + guestName = "world" + } + + return map[string]interface{}{ + "message": buildGreeting(guestName), + "runtime": "go1.23", + "event": event, + } +} diff --git a/hello-go/http.tf b/hello-go/http.tf new file mode 100644 index 0000000..d24bff0 --- /dev/null +++ b/hello-go/http.tf @@ -0,0 +1,23 @@ +# 2026-03-11 +# http.tf — HTTP-функция на Go: принимает запрос, возвращает приветствие. + +resource "sless_function" "hello_go_http" { + name = "hello-go-http" + runtime = "go1.23" + entrypoint = "handler.Handle" + memory_mb = 128 + timeout_sec = 60 + + source_dir = "${path.module}/code" +} + +resource "sless_trigger" "hello_go_http" { + name = "hello-go-http-trigger" + type = "http" + function = sless_function.hello_go_http.name + enabled = true +} + +output "trigger_url" { + value = sless_trigger.hello_go_http.url +} diff --git a/hello-go/job.tf b/hello-go/job.tf new file mode 100644 index 0000000..9a70769 --- /dev/null +++ b/hello-go/job.tf @@ -0,0 +1,28 @@ +# 2026-03-11 +# job.tf — одноразовый запуск Go функции с event payload. + +resource "sless_function" "hello_go_job" { + name = "hello-go-job" + runtime = "go1.23" + entrypoint = "handler.Handle" + memory_mb = 128 + timeout_sec = 60 + + source_dir = "${path.module}/code" +} + +resource "sless_job" "hello_go_run" { + name = "hello-go-run" + function = sless_function.hello_go_job.name + event_json = jsonencode({ name = "Go" }) + wait_timeout_sec = 300 + run_id = 1 +} + +output "job_phase" { + value = sless_job.hello_go_run.phase +} + +output "job_message" { + value = sless_job.hello_go_run.message +} diff --git a/hello-go/main.tf b/hello-go/main.tf new file mode 100644 index 0000000..137d8b2 --- /dev/null +++ b/hello-go/main.tf @@ -0,0 +1,17 @@ +# 2026-03-11 +# main.tf — провайдеры для hello-go примера. + +terraform { + required_providers { + sless = { + source = "terra.k8c.ru/naeel/sless" + version = "~> 0.1.14" + } + } +} + +provider "sless" { + endpoint = "https://sless-api.kube5s.ru" + token = var.token + nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1" +} diff --git a/hello-go/terraform.tfvars b/hello-go/terraform.tfvars new file mode 100644 index 0000000..757bc22 --- /dev/null +++ b/hello-go/terraform.tfvars @@ -0,0 +1 @@ +token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLWFwaSIsInN1YiI6IjAxOTllMzI1LTFjZGYtN2NkYS05MzE5LWU1MzAyYTg1ZTI5MSIsImV4cCI6MTc4NjkzMjI2MCwiaWF0IjoxNzcxMzgwMjYwLCJqdGkiOiIzOTQ3ZTgyMy0yNjljLTQ0MTAtYmU0My1iNGVkNTc1Njg0ZTQiLCJhdXRoX3RpbWUiOjAsInR5cCI6IiIsImF6cCI6IiIsInNlc3Npb25fc3RhdGUiOiIiLCJhY3IiOiIiLCJhbGxvd2VkLW9yaWdpbnMiOm51bGwsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6bnVsbH0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpudWxsfX0sInNjb3BlIjoiIiwic2lkIjoiIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuYW1lIjoiIiwiQ2xpZW50SUQiOiIiLCJncm91cHMiOm51bGwsInByZWZlcnJlZF91c2VybmFtZSI6IiIsImdpdmVuX25hbWUiOiIiLCJmYW1pbHlfbmFtZSI6IiIsImVtYWlsIjoidGF6ZXRAbmFyb2QucnUifQ.hzpIIqNWkKIoYUXDaLY7DLyGKH70rz0ZTqanv19qxF10i3N1t1g_KknA4Qsw1MduTyLzIz7y5SRSr4PSQ1gzR0vB_C0GudSFUhyBNNKkS4ClhRDWW9eN_IIEljbiJMLQi2L07XJ7Y5DQ0sIHRAPLkreCDFMKQ0yTCrKoScCJIDuUqzaTcOaX-hfjaxW8iV0SZMDxl0C5O3tke0btxkaLBaAcWH0V-1yu2r2m29fyU33FqikF0xAcDXiuZphfsrShKQYArZjKAphYCP_Vpmr-1sdjinkn8sPSk1qZny0rka8G6WVZUGaZSOnW8SYNLVUwdqtuQmK-Y18o7U0Suzrsjg" diff --git a/hello-go/variables.tf b/hello-go/variables.tf new file mode 100644 index 0000000..538fc2b --- /dev/null +++ b/hello-go/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/pg-list-python/code/catalog.py b/pg-list-python/code/catalog.py new file mode 100644 index 0000000..2095879 --- /dev/null +++ b/pg-list-python/code/catalog.py @@ -0,0 +1,47 @@ +# 2026-03-11 +# catalog.py — читает список продуктов из PostgreSQL. +# Таблица demo_products создаётся автоматически при первом вызове. +# Точка входа: list_products(event) + +import json +import os +import psycopg2 + + +def list_products(event): + dsn = os.environ["PG_DSN"] + conn = psycopg2.connect(dsn) + try: + with conn.cursor() as cur: + _ensure_table(cur) + conn.commit() + + cur.execute("SELECT id, name, price FROM demo_products ORDER BY id") + rows = cur.fetchall() + finally: + conn.close() + + products = [{"id": row[0], "name": row[1], "price": float(row[2])} for row in rows] + return {"products": products, "count": len(products)} + + +def _ensure_table(cur): + # Создаём таблицу и наполняем демо-данными — только один раз + cur.execute(""" + CREATE TABLE IF NOT EXISTS demo_products ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + price NUMERIC(10,2) NOT NULL + ) + """) + cur.execute("SELECT COUNT(*) FROM demo_products") + if cur.fetchone()[0] == 0: + cur.executemany( + "INSERT INTO demo_products (name, price) VALUES (%s, %s)", + [ + ("Ноутбук", 89999.00), + ("Мышь", 1299.00), + ("Клавиатура", 3499.00), + ("Монитор", 32000.00), + ], + ) diff --git a/pg-list-python/code/requirements.txt b/pg-list-python/code/requirements.txt new file mode 100644 index 0000000..37ec460 --- /dev/null +++ b/pg-list-python/code/requirements.txt @@ -0,0 +1 @@ +psycopg2-binary diff --git a/pg-list-python/function.tf b/pg-list-python/function.tf new file mode 100644 index 0000000..df1f7c2 --- /dev/null +++ b/pg-list-python/function.tf @@ -0,0 +1,29 @@ +# 2026-03-11 +# function.tf — HTTP-функция: читает из PostgreSQL и возвращает список записей. +# Нет джобов, нет инициализации — только функция + HTTP триггер. + +resource "sless_function" "product_catalog" { # объявляем serverless-функцию; "product_catalog" — локальное имя в tf-state + name = "product-catalog" # имя функции в кластере; по нему формируется URL и имя k8s-объекта + runtime = "python3.11" # базовый образ рантайма; определяет как собирается и запускается код + entrypoint = "catalog.list_products" # файл.функция которую вызывает рантайм: catalog.py → def list_products(event) + memory_mb = 128 # лимит памяти пода в мегабайтах + timeout_sec = 10 # максимальное время выполнения одного запроса в секундах + + source_dir = "${path.module}/code" # директория с кодом функции; провайдер упакует её в zip и загрузит + + # DSN передаётся через env — функция не знает об инфраструктуре + env_vars = { + PG_DSN = var.pg_dsn # строка подключения к PostgreSQL; берётся из переменной (variables.tf) + } +} + +resource "sless_trigger" "product_catalog_http" { # триггер публикует функцию наружу; без него функция существует, но недоступна + name = "product-catalog-http" # имя триггера в кластере + type = "http" # тип триггера: "http" создаёт публичный URL; альтернатива — "cron" + function = sless_function.product_catalog.name # ссылка на имя функции выше; terraform гарантирует порядок создания + enabled = true # триггер активен сразу после создания +} + +output "catalog_url" { + value = sless_trigger.product_catalog_http.url # URL вида https://sless-api.../fn//; выводится после apply +} diff --git a/pg-list-python/main.tf b/pg-list-python/main.tf new file mode 100644 index 0000000..7d3d427 --- /dev/null +++ b/pg-list-python/main.tf @@ -0,0 +1,17 @@ +# 2026-03-11 +# main.tf — провайдер для pg-list-python примера. + +terraform { + required_providers { + sless = { + source = "terra.k8c.ru/naeel/sless" + version = "~> 0.1.14" + } + } +} + +provider "sless" { + endpoint = "https://sless-api.kube5s.ru" + token = var.token + nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1" +} diff --git a/pg-list-python/terraform.tfvars b/pg-list-python/terraform.tfvars new file mode 100644 index 0000000..757bc22 --- /dev/null +++ b/pg-list-python/terraform.tfvars @@ -0,0 +1 @@ +token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLWFwaSIsInN1YiI6IjAxOTllMzI1LTFjZGYtN2NkYS05MzE5LWU1MzAyYTg1ZTI5MSIsImV4cCI6MTc4NjkzMjI2MCwiaWF0IjoxNzcxMzgwMjYwLCJqdGkiOiIzOTQ3ZTgyMy0yNjljLTQ0MTAtYmU0My1iNGVkNTc1Njg0ZTQiLCJhdXRoX3RpbWUiOjAsInR5cCI6IiIsImF6cCI6IiIsInNlc3Npb25fc3RhdGUiOiIiLCJhY3IiOiIiLCJhbGxvd2VkLW9yaWdpbnMiOm51bGwsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6bnVsbH0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpudWxsfX0sInNjb3BlIjoiIiwic2lkIjoiIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuYW1lIjoiIiwiQ2xpZW50SUQiOiIiLCJncm91cHMiOm51bGwsInByZWZlcnJlZF91c2VybmFtZSI6IiIsImdpdmVuX25hbWUiOiIiLCJmYW1pbHlfbmFtZSI6IiIsImVtYWlsIjoidGF6ZXRAbmFyb2QucnUifQ.hzpIIqNWkKIoYUXDaLY7DLyGKH70rz0ZTqanv19qxF10i3N1t1g_KknA4Qsw1MduTyLzIz7y5SRSr4PSQ1gzR0vB_C0GudSFUhyBNNKkS4ClhRDWW9eN_IIEljbiJMLQi2L07XJ7Y5DQ0sIHRAPLkreCDFMKQ0yTCrKoScCJIDuUqzaTcOaX-hfjaxW8iV0SZMDxl0C5O3tke0btxkaLBaAcWH0V-1yu2r2m29fyU33FqikF0xAcDXiuZphfsrShKQYArZjKAphYCP_Vpmr-1sdjinkn8sPSk1qZny0rka8G6WVZUGaZSOnW8SYNLVUwdqtuQmK-Y18o7U0Suzrsjg" diff --git a/pg-list-python/variables.tf b/pg-list-python/variables.tf new file mode 100644 index 0000000..ab59478 --- /dev/null +++ b/pg-list-python/variables.tf @@ -0,0 +1,14 @@ +# 2026-03-11 +# variables.tf + +variable "token" { + description = "JWT токен облака" + type = string + sensitive = true +} + +variable "pg_dsn" { + description = "DSN подключения к PostgreSQL" + type = string + default = "postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable" +} diff --git a/push-sample/Dockerfile b/push-sample/Dockerfile deleted file mode 100644 index faa19a5..0000000 --- a/push-sample/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -# 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 deleted file mode 100644 index 51043a0..0000000 --- a/push-sample/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# Пример для пуша в 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 deleted file mode 100755 index 92f563f..0000000 --- a/push-sample/build_and_push.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/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 deleted file mode 100755 index 0ea985a..0000000 --- a/run_terraform_examples.sh +++ /dev/null @@ -1,333 +0,0 @@ -#!/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