diff --git a/doc/api/design.md b/doc/api/design.md index a61c1d7..a6b8e2b 100644 --- a/doc/api/design.md +++ b/doc/api/design.md @@ -1,5 +1,7 @@ # API Design +Последнее обновление: 2026-03-18 + ## Базовый URL ``` @@ -7,15 +9,37 @@ http://:9090/v1 ``` Локально: `http://localhost:9090/v1` -В кластере (будущее): `http://sless-operator.sless.svc.cluster.local:9090/v1` +В кластере: `http://sless-operator.sless.svc.cluster.local:9090/v1` +Публично: `https://sless.kube5s.ru/v1/...` (через Ingress) -**Реализовано** (текущий namespace-aware формат): +**Реализованные эндпоинты:** ``` -/v1/namespaces/{namespace}/functions[/{name}] -/v1/namespaces/{namespace}/functions/{name}/upload -/v1/namespaces/{namespace}/triggers[/{name}] -/v1/namespaces/{namespace}/functions/{name}/invocations +GET /v1/namespaces/{ns}/functions +POST /v1/namespaces/{ns}/functions +GET /v1/namespaces/{ns}/functions/{name} +DELETE /v1/namespaces/{ns}/functions/{name} +POST /v1/namespaces/{ns}/functions/{name}/upload +GET /v1/namespaces/{ns}/triggers +POST /v1/namespaces/{ns}/triggers +GET /v1/namespaces/{ns}/triggers/{name} +DELETE /v1/namespaces/{ns}/triggers/{name} +GET /v1/namespaces/{ns}/functions/{name}/invocations +``` + +**Запланированные эндпоинты (ветка feat/web-console):** + +``` +GET /v1/namespaces/{ns}/functions/{name}/source ← НОВЫЙ: код из S3 zip +PATCH /v1/namespaces/{ns}/triggers/{name} ← НОВЫЙ: enable/disable триггера +``` + +**Глобальный сервис funcs (не оператор):** + +``` +GET https://sless.kube5s.ru/funcs/ ← без токена, plain text / HTML +GET https://sless.kube5s.ru/funcs?token= ← с токеном +GET https://sless.kube5s.ru/health ← liveness probe ``` ## Аутентификация @@ -24,6 +48,11 @@ http://:9090/v1 Authorization: Bearer ``` +Токен — JWT от `auth-api`. Middleware в операторе: +1. Извлекает `sub` из payload (без проверки подписи — доверяет Ingress) +2. Вычисляет namespace: `SHA256(sub)[:8]` hex → `sless-{16 hex символов}` +3. Проверяет что запрошенный `{namespace}` совпадает с вычисленным + ## Ресурсы ### Functions diff --git a/doc/architecture/overview.md b/doc/architecture/overview.md index 05bd707..eea641a 100644 --- a/doc/architecture/overview.md +++ b/doc/architecture/overview.md @@ -1,6 +1,6 @@ # Архитектура системы -Последнее обновление: 2026-03-11 (v0.1.22) +Последнее обновление: 2026-03-18 (v0.1.33 + funcs-service v0.1.3) ## Общее описание @@ -13,6 +13,7 @@ Managed Serverless Functions Service для облачного провайде | Компонент | Технология | Где запущен | |-----------|-----------|-------------| | Operator (API + Controllers) | Go (controller-runtime) | Kubernetes, namespace `sless` | +| funcs-service (глобальная консоль) | Go (net/http) | Kubernetes, namespace `sless` | | PostgreSQL | PostgreSQL 16 | Kubernetes, namespace `sless` | | S3 | Ceph (облачный) | `s3.msk-1.ngcloud.ru` | | Container Registry | DockerHub (`naeel/`) | внешний | @@ -25,6 +26,44 @@ Managed Serverless Functions Service для облачного провайде > Redis и RabbitMQ — отложены до v2. +## Компонент: funcs-service + +Глобальный HTTP сервис — **одна копия** на весь кластер, для всех пользователей. + +``` +User Browser / curl + └─► https://sless.kube5s.ru/funcs/ + └─► nginx Ingress (sless-funcs-ingress) + └─► sless-funcs-service:8090 (namespace sless) + └─► http://sless-operator.sless.svc.cluster.local:9090/v1/... +``` + +**Файлы:** +- `services/funcs/main.go` — логика +- `services/funcs/Dockerfile` — multi-stage Go → alpine +- `deployments/k8s/funcs-service.yaml` — Deployment + Service + Ingress + +**Env vars сервиса:** +| Переменная | Значение | +|-----------|---------| +| `SLESS_OPERATOR_URL` | `http://sless-operator.sless.svc.cluster.local:9090` | +| `SLESS_EXTERNAL_URL` | `https://sless.kube5s.ru` | +| `SLESS_EXCLUDE` | `event-writer,event-monitor,event-cleaner` | +| `SLESS_SERVICE_TOKEN` | JWT токен (задаётся через `kubectl set env`, не в git) | + +## Хранение кода функций в S3 + +``` +Terraform source_dir (локально) + └─► zip → POST /upload → builder.PrepareContext() + ├─► functions/{ns}/{name}/{ts}.zip ← ИСХОДНЫЙ КОД пользователя + └─► contexts/{ns}/{name}/{ts}.tar.gz ← BUILD CONTEXT для kaniko + └─► Function CRD: spec.s3Key = "contexts/..." + └─► контроллер → kaniko Job → Docker image +``` + +Для web-консоли: `GET /source` читает `functions/{ns}/{name}/{ts}.zip` напрямую. + ## Изоляция пользователей — Namespace per user Каждый пользователь облака получает **отдельный k8s namespace**. diff --git a/doc/decisions/log.md b/doc/decisions/log.md index cb244a5..bb31c6c 100644 --- a/doc/decisions/log.md +++ b/doc/decisions/log.md @@ -1,5 +1,62 @@ # Решения и обоснования +## 2026-03-18 — Архитектура: funcs как глобальный сервис, web-консоль + +### Хранение кода функций + +S3 (minio внутри кластера) хранит **два артефакта** на каждый upload: + +``` +functions/{namespace}/{name}/{timestamp}.zip ← ИСХОДНЫЙ КОД (zip пользователя) +contexts/{namespace}/{name}/{timestamp}.tar.gz ← BUILD CONTEXT для kaniko (zip + Dockerfile) +``` + +Function CRD хранит `spec.s3Key` — указывает на `contexts/...` (build context). +Из него можно восстановить путь к исходному zip: +`contexts/{ns}/{name}/{ts}.tar.gz` → `functions/{ns}/{name}/{ts}.zip` + +Поэтому для отображения кода в веб-консоли **не нужно ничего менять в CRD**: +достаточно нового эндпоинта `GET /source` который читает zip из S3. + +### Решение: funcs как глобальный Go сервис вместо per-user terraform + +**Было:** `sless_function.funcs_list` + `sless_trigger.funcs_list_http` в `examples/POSTGRES/resources.tf` +— Для каждого пользователя terraform создавал отдельный pod функции +— Требовал `api_token`, `SLESS_NAMESPACE` как env vars в terraform +— Не масштабируется: N пользователей = N лишних pod'ов + +**Стало:** `services/funcs/main.go` — один Go HTTP сервис в namespace `sless` +— Деплоится один раз через `deployments/k8s/funcs-service.yaml` +— Принимает JWT токен → извлекает `sub` → `SHA256[:8]` → namespace +— URL без токена: `/funcs/` (namespace не секрет — виден в URL каждой функции) +— `SLESS_SERVICE_TOKEN` задаётся через `kubectl set env` (не хранится в git) + +### Про будущую синхронизацию terraform-папок с кластером + +Terraform уже работает по схеме: `source_dir` → zip → `POST /upload` → S3. +Обратная синхронизация (кластер → локальная папка): скачать zip из S3 → распаковать в `source_dir`. +Никаких структурных изменений не потребует. Реализовывать ПОСЛЕ web-консоли. + +### Архитектура web-консоли (план, ветка feat/web-console) + +**Принцип:** минимум изменений в операторе, максимум логики в `sless-funcs-service`. + +**Два новых эндпоинта в операторе:** + +| Метод | Путь | Что делает | +|-------|------|-----------| +| GET | `/v1/namespaces/{ns}/functions/{name}/source` | Читает zip из S3 → JSON `[{name, content}]` | +| PATCH | `/v1/namespaces/{ns}/triggers/{name}` | `{"enabled": bool}` → обновляет Trigger CRD | + +**`sless-funcs-service` — HTML режим:** +- Если запрос из браузера (`Accept: text/html`) → отдаёт HTML страницу +- Список функций — аккордеон; при раскрытии `fetch(/source)` подгружает файлы +- Подсветка синтаксиса: `highlight.js` с CDN (не требует сборки) +- Кнопки ▶ Старт / ■ Стоп → `PATCH /triggers/{name}` через fetch +- `text/plain` ответ для curl/CLI остаётся без изменений + +--- + ## 2026-03-18 — Смена sless API endpoint: sless-api.kube5s.ru → sless.kube5s.ru **Решение:** Оператор sless доступен по `https://sless.kube5s.ru` (не `sless-api.kube5s.ru`). Все examples, deployments и ConfigMap обновлены. diff --git a/doc/design.md b/doc/design.md new file mode 100644 index 0000000..a6b8e2b --- /dev/null +++ b/doc/design.md @@ -0,0 +1,159 @@ +# API Design + +Последнее обновление: 2026-03-18 + +## Базовый URL + +``` +http://:9090/v1 +``` + +Локально: `http://localhost:9090/v1` +В кластере: `http://sless-operator.sless.svc.cluster.local:9090/v1` +Публично: `https://sless.kube5s.ru/v1/...` (через Ingress) + +**Реализованные эндпоинты:** + +``` +GET /v1/namespaces/{ns}/functions +POST /v1/namespaces/{ns}/functions +GET /v1/namespaces/{ns}/functions/{name} +DELETE /v1/namespaces/{ns}/functions/{name} +POST /v1/namespaces/{ns}/functions/{name}/upload +GET /v1/namespaces/{ns}/triggers +POST /v1/namespaces/{ns}/triggers +GET /v1/namespaces/{ns}/triggers/{name} +DELETE /v1/namespaces/{ns}/triggers/{name} +GET /v1/namespaces/{ns}/functions/{name}/invocations +``` + +**Запланированные эндпоинты (ветка feat/web-console):** + +``` +GET /v1/namespaces/{ns}/functions/{name}/source ← НОВЫЙ: код из S3 zip +PATCH /v1/namespaces/{ns}/triggers/{name} ← НОВЫЙ: enable/disable триггера +``` + +**Глобальный сервис funcs (не оператор):** + +``` +GET https://sless.kube5s.ru/funcs/ ← без токена, plain text / HTML +GET https://sless.kube5s.ru/funcs?token= ← с токеном +GET https://sless.kube5s.ru/health ← liveness probe +``` + +## Аутентификация + +``` +Authorization: Bearer +``` + +Токен — JWT от `auth-api`. Middleware в операторе: +1. Извлекает `sub` из payload (без проверки подписи — доверяет Ingress) +2. Вычисляет namespace: `SHA256(sub)[:8]` hex → `sless-{16 hex символов}` +3. Проверяет что запрошенный `{namespace}` совпадает с вычисленным + +## Ресурсы + +### Functions + +| Метод | Путь | Описание | +|-------|------|----------| +| GET | /functions | Список функций | +| POST | /functions | Создать функцию | +| GET | /functions/{id} | Получить функцию | +| PUT | /functions/{id} | Обновить функцию | +| DELETE | /functions/{id} | Удалить функцию | + +### Versions (код функции) + +| Метод | Путь | Описание | +|-------|------|----------| +| GET | /functions/{id}/versions | Список версий | +| POST | /functions/{id}/versions | Загрузить новый код (multipart zip) | +| GET | /functions/{id}/versions/{ver} | Получить версию | +| POST | /functions/{id}/versions/{ver}/activate | Активировать версию | + +### Triggers + +| Метод | Путь | Описание | +|-------|------|----------| +| GET | /functions/{id}/triggers | Список триггеров | +| POST | /functions/{id}/triggers | Создать триггер (HTTP/Cron) | +| DELETE | /functions/{id}/triggers/{tid} | Удалить триггер | + +### Invocations (вызов и логи) + +| Метод | Путь | Описание | +|-------|------|----------| +| POST | /functions/{id}/invoke | Синхронный вызов | +| GET | /functions/{id}/invocations | История вызовов | +| GET | /functions/{id}/invocations/{iid} | Детали вызова + логи | + +## Upload endpoint + +``` +POST /v1/namespaces/{namespace}/functions/{name}/upload +Content-Type: multipart/form-data +Authorization: Bearer + +field: code = +``` + +Процесс: +1. Принимает zip (max 32MB) +2. Распаковывает zip +3. Генерирует `Dockerfile` (`FROM naeel/sless-runtime-{runtime}:latest\nCOPY . /app/function/`) +4. Перепаковывает в `tar.gz` (kaniko требует tar format) +5. Загружает в S3: `contexts/{ns}/{name}/{timestamp}.tar.gz` +6. Обновляет `fn.Spec.S3Key` → контроллер видит изменение и запускает kaniko Job + +Ответ `200 OK`: +```json +{"message": "build queued", "phase": "Pending", "s3_key": "contexts/..."} +``` + +## Поддерживаемые runtime (v1) + +- `python3.11` — реализован и протестирован +- `go1.21` — планируется +- `nodejs20` — планируется + +## Модель Function + +```json +{ + "id": "fn-uuid", + "name": "my-function", + "description": "...", + "runtime": "python3.11", + "entrypoint": "handler.handle", + "memory_mb": 128, + "timeout_sec": 30, + "env_vars": {"KEY": "value"}, + "active_version": "1", + "status": "active", + "created_at": "...", + "updated_at": "..." +} +``` + +## Модель Trigger + +```json +{ + "id": "tr-uuid", + "type": "http", + "url": "https://sless.api.ngcloud.ru/invoke/fn-uuid", + "created_at": "..." +} +``` + +```json +{ + "id": "tr-uuid", + "type": "cron", + "schedule": "0 * * * *", + "created_at": "..." +} +``` diff --git a/doc/errors/log.md b/doc/errors/log.md index 3cf7a43..e924a7f 100644 --- a/doc/errors/log.md +++ b/doc/errors/log.md @@ -2,6 +2,190 @@ > Сюда записываем проблемы с которыми столкнулись и как их решили. +--- + +## 2026-03-17 — Баг 3: PodLogOptions compile error (v0.1.31) + +**Проблема:** Оператор не компилировался. Ошибка: +``` +controllers/functionjob_controller.go: unknown field Stdout in corev1.PodLogOptions +controllers/functionjob_controller.go: unknown field Stderr in corev1.PodLogOptions +``` + +**Причина:** `corev1.PodLogOptions{}` в k8s API не имеет полей `Stdout` и `Stderr` — это поля из `corev1.ContainerState`. Код выглядел так: +```go +req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{ + Stdout: true, // не существует + Stderr: true, // не существует +}) +``` + +**Решение:** Убрать несуществующие поля. `GetLogs` по умолчанию возвращает stdout+stderr без дополнительных флагов: +```go +req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{}) +``` + +**Файл:** `controllers/functionjob_controller.go`, функция `getJobPodOutput` +**Версия:** исправлено в `naeel/sless-operator:v0.1.31` + +--- + +## 2026-03-17 — Главная причина провала POSTGRES example: неправильный nubes_endpoint + +### Симптом + +`terraform apply` для `examples/POSTGRES` падал с `"job failed, check pod logs"`. При этом `examples/simple-python` и `examples/hello-node` работали нормально на тест-стенде. + +### Корневая причина + +В `examples/POSTGRES/main.tf` в блоке `provider "sless"` был указан **продовый** endpoint Nubes API: + +```hcl +provider "sless" { + endpoint = "https://sless-api.kube5s.ru" + token = var.api_token + nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1" # ← ПРОД +} +``` + +При этом `provider "nubes"` уже указывал на тест: +```hcl +provider "nubes" { + api_token = var.api_token + api_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm" # ← тест ✓ +} +``` + +Из-за этого `sless_function` и `sless_job` стучались в **продовый** Nubes API, где у тестового пользователя другой namespace и другой кластер. Postgres и pod запускались в контексте прода, а не тест-стенда. Поды не могли подключиться к базе. + +### Фикс + +```hcl +# examples/POSTGRES/main.tf +provider "sless" { + endpoint = "https://sless-api.kube5s.ru" + token = var.api_token + nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1" # ← тест +} +``` + +Аналогично исправлено в `examples/simple-python/main.tf`. + +### Почему долго искали + +Долгое расследование ушло на "split-brain" — API-созданные FunctionJob'ы были невидимы через `kubectl get functionjobs -A`. Это объяснялось тем что: +1. Job завершался (Failed) за ~5 секунд +2. Между завершением и опросом kubectl объект уже мог быть очищен +3. Параллельно в кластере фигурировал другой namespace (`sless-ffd1f598c169b0ae` — прод) vs тест-namespace + +На самом деле контроллер работал корректно. Проблема была исключительно в `nubes_endpoint`. + +### Урок + +**Всегда проверять**: когда в `main.tf` два провайдера (`nubes` + `sless`) — у обоих endpoint'ы должны указывать на **одну среду**. Смешивание прод/тест endpoint'ов в одном apply даёт непредсказуемые результаты. + +--- + +## 2026-03-17 — Ошибка агента: локальный запуск terraform + +**Проблема:** Агент запускал `terraform apply` **локально** (`/home/naeel/remote_dev/sless/examples/...`) вместо запуска на удалённом сервере `naeel@5.172.178.213`. + +**Почему неправильно:** +- Провайдер `terra.k8c.ru/naeel/sless` установлен только на удалённом сервере +- Локальный terraform не имеет доступа к k8s кластеру напрямую +- Потенциально затрагивал продовые API из локальной сети + +**Правило:** Все `terraform init/plan/apply/destroy` для `examples/` — только через SSH на `naeel@5.172.178.213`: +```bash +ssh -i /home/naeel/remote_dev/common/id_ed25519.txt naeel@5.172.178.213 \ + 'cd /home/naeel/terra/sless/examples/ && terraform apply -auto-approve -no-color' +``` + +--- + +## 2026-03-17 — FunctionJob всегда "job failed, check pod logs" — расследование и два бага + +### Хронология расследования + +**Симптом:** `terraform apply` на `examples/POSTGRES` завершается ошибкой: +``` +job failed: job failed, check pod logs: kubectl logs -n sless-fn-sless-ffd1f598c169b0ae -l functionjob=pg-create-table-job-main-v12 +``` +Команда из сообщения — ничего не выдаёт (под не найден). + +--- + +**Ошибка агента №1 — SQL-диагностика вместо анализа кода** + +Первая реакция — запустить диагностические поды в кластере с psycopg2 чтобы проверить подключение к PostgreSQL. Это было **неправильно**: проблема не в подключении, а в том что job не запускался вообще. Пользователь остановил — "ПРИ ЧЁМ тут SQL запросы???". + +--- + +**Ошибка агента №2 — не читал собственный код** + +Раньше читались логи оператора и kubectl describe — но не исходный код контроллеров. Пользователь указал: "мы пишем ВСЁ сами, смотри в код". После прочтения `functionjob_controller.go` и `functions.go` картина сложилась за одно чтение. + +--- + +### Баг 1 — k8s label `job-name` удалён в 1.27+ + +**Файл:** `controllers/functionjob_controller.go`, функция `getJobPodOutput` + +**Код до:** +```go +pods, err := kube.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: "job-name=" + jobName, +}) +if err != nil || len(pods.Items) == 0 { + return "completed successfully" // ← сюда всегда попадали +} +``` + +**Причина:** В k8s 1.27 лейбл `job-name` на pod-ах был deprecated, в 1.32+ удалён полностью. У нас кластер **1.34.1**. Поэтому `List` возвращал 0 подов всегда → fallback `"completed successfully"` → при `phase=Failed` финальное сообщение `"job failed, check pod logs: ..."`. + +Логи реального сбоя никогда не попадали в `status.Message` FunctionJob, поэтому **корневая ошибка Python-функции была невидима**. + +**Как нашли:** Проверили `kubectl get pod -l "batch.kubernetes.io/job-name"` — вернул поды. `kubectl get pod -l "job-name"` — не вернул. Версия кластера: `v1.34.1` (kubectl `v1.35.2`). + +**Фикс:** Использовать собственный лейбл `functionjob=`, который мы сами выставляем на PodTemplate и который работает независимо от версии k8s. + +--- + +**Также:** В строке 211 подсказка для пользователя тоже использовала устаревший лейбл: +```go +"job failed, check pod logs: kubectl logs -n " + job.Namespace + " -l job-name=" + job.Name +``` +Исправлено на `functionjob=` — теперь команда реально работает. + +--- + +### Баг 2 — Split-brain cached client при CreateFunction + +**Файл:** `internal/api/handler/functions.go`, функция `CreateFunction` + +**Проявление:** API возвращает `409 "function already exists"`, а `kubectl get function -A` функцию не видит. `terraform state rm sless_function.*` не помогает — следующий `terraform apply` снова получает 409. + +**Причина:** `h.K8s` в handler — это **cached client** controller-runtime. Цепочка: +1. Предыдущий `terraform destroy` вызвал `DELETE /functions/pg-create-table-runner` +2. Handler вызвал `h.K8s.Delete(ctx, fn)` — объект удалён из **etcd** +3. Кеш controller-runtime обновляется асинхронно (informer watch). Несколько секунд объект ещё жив в памяти оператора +4. Следующий `terraform apply` → `POST /functions` → `h.K8s.Create(ctx, fn)` → `IsAlreadyExists` (кеш ещё видит объект) +5. Код проверяет `phase == Failed` — но объект в кеше в фазе `Ready` → уходит в 409 навсегда + +`kubectl get function` шёл **мимо кеша** (прямо в k8s API) → NotFound. API handler шёл **через кеш** → AlreadyExists. Поэтому они показывали разные результаты. + +**Фикс:** При `IsAlreadyExists` делать `uncached Get` (через `client.ObjectKey` напрямую). Если объект реально не найден в etcd (`IsNotFound`) — значит кеш устарел, смело создаём заново. Если найден — возвращаем 409 как обычно (объект реально существует). + +--- + +### Итог + +| # | Баг | Файл | Строка | Тип | +|---|-----|------|--------|-----| +| 1 | `LabelSelector: "job-name="` deprecated k8s 1.27+ | `controllers/functionjob_controller.go` | 311 | Совместимость | +| 1b | Подсказка `kubectl logs -l job-name=` тоже устарела | `controllers/functionjob_controller.go` | 211 | UX | +| 2 | Cached client → perpetual 409 при CreateFunction | `internal/api/handler/functions.go` | 117-133 | Race condition | + ## Шаблон записи ``` diff --git a/doc/log.md b/doc/log.md new file mode 100644 index 0000000..bb31c6c --- /dev/null +++ b/doc/log.md @@ -0,0 +1,763 @@ +# Решения и обоснования + +## 2026-03-18 — Архитектура: funcs как глобальный сервис, web-консоль + +### Хранение кода функций + +S3 (minio внутри кластера) хранит **два артефакта** на каждый upload: + +``` +functions/{namespace}/{name}/{timestamp}.zip ← ИСХОДНЫЙ КОД (zip пользователя) +contexts/{namespace}/{name}/{timestamp}.tar.gz ← BUILD CONTEXT для kaniko (zip + Dockerfile) +``` + +Function CRD хранит `spec.s3Key` — указывает на `contexts/...` (build context). +Из него можно восстановить путь к исходному zip: +`contexts/{ns}/{name}/{ts}.tar.gz` → `functions/{ns}/{name}/{ts}.zip` + +Поэтому для отображения кода в веб-консоли **не нужно ничего менять в CRD**: +достаточно нового эндпоинта `GET /source` который читает zip из S3. + +### Решение: funcs как глобальный Go сервис вместо per-user terraform + +**Было:** `sless_function.funcs_list` + `sless_trigger.funcs_list_http` в `examples/POSTGRES/resources.tf` +— Для каждого пользователя terraform создавал отдельный pod функции +— Требовал `api_token`, `SLESS_NAMESPACE` как env vars в terraform +— Не масштабируется: N пользователей = N лишних pod'ов + +**Стало:** `services/funcs/main.go` — один Go HTTP сервис в namespace `sless` +— Деплоится один раз через `deployments/k8s/funcs-service.yaml` +— Принимает JWT токен → извлекает `sub` → `SHA256[:8]` → namespace +— URL без токена: `/funcs/` (namespace не секрет — виден в URL каждой функции) +— `SLESS_SERVICE_TOKEN` задаётся через `kubectl set env` (не хранится в git) + +### Про будущую синхронизацию terraform-папок с кластером + +Terraform уже работает по схеме: `source_dir` → zip → `POST /upload` → S3. +Обратная синхронизация (кластер → локальная папка): скачать zip из S3 → распаковать в `source_dir`. +Никаких структурных изменений не потребует. Реализовывать ПОСЛЕ web-консоли. + +### Архитектура web-консоли (план, ветка feat/web-console) + +**Принцип:** минимум изменений в операторе, максимум логики в `sless-funcs-service`. + +**Два новых эндпоинта в операторе:** + +| Метод | Путь | Что делает | +|-------|------|-----------| +| GET | `/v1/namespaces/{ns}/functions/{name}/source` | Читает zip из S3 → JSON `[{name, content}]` | +| PATCH | `/v1/namespaces/{ns}/triggers/{name}` | `{"enabled": bool}` → обновляет Trigger CRD | + +**`sless-funcs-service` — HTML режим:** +- Если запрос из браузера (`Accept: text/html`) → отдаёт HTML страницу +- Список функций — аккордеон; при раскрытии `fetch(/source)` подгружает файлы +- Подсветка синтаксиса: `highlight.js` с CDN (не требует сборки) +- Кнопки ▶ Старт / ■ Стоп → `PATCH /triggers/{name}` через fetch +- `text/plain` ответ для curl/CLI остаётся без изменений + +--- + +## 2026-03-18 — Смена sless API endpoint: sless-api.kube5s.ru → sless.kube5s.ru + +**Решение:** Оператор sless доступен по `https://sless.kube5s.ru` (не `sless-api.kube5s.ru`). Все examples, deployments и ConfigMap обновлены. + +**Причина:** При пересоздании кластера DNS-запись `sless-api.kube5s.ru` не была обновлена — она указывала на IP `5.172.178.182` (старый, мёртвый кластер). Ingress нового кластера закреплён на `185.247.187.147`. Отдельная запись `sless.kube5s.ru` уже корректно указывала на `185.247.187.147`. +TLS handshake timeout возникал потому что старый IP принимал TCP:443, но не завершал TLS (nginx жив, бэкенд мёртв). Go HTTP клиент ждал системный таймаут (~90s) и повторял бесконечно. + +**Изменения:** +- `deployments/k8s/operator.yaml`: `EXTERNAL_URL`, `INGRESS_HOST`, ingress host → `sless.kube5s.ru` +- ConfigMap `sless-operator-config` в кластере: `EXTERNAL_URL` обновлён через `kubectl patch` +- Ingress `sless-operator` в кластере: host + TLS secret → `sless.kube5s.ru` +- Все `examples/**/main.tf`: `endpoint = "https://sless.kube5s.ru"` + +**Правило:** При пересоздании кластера — первым делом проверять соответствие DNS → ingress IP. + +--- + +## 2026-03-17 — Разделение prod/test endpoint'ов в examples/ + +**Решение:** Все `examples/` ОБЯЗАНЫ использовать `deck-api-test.ngcloud.ru` для обоих провайдеров: `nubes` и `sless` (`nubes_endpoint`). Продовый `deck-api.ngcloud.ru` — только для реальных клиентов. + +**Причина:** Смешивание prod и test endpoint'ов в одном `terraform apply` приводит к тому что ресурсы создаются в разных средах. `sless_function`/`sless_job` могут получать данные (PGHOST, credentials) из прода, а pod запускается в тест-кластере — и не может достучаться до хоста. + +**Правило для `main.tf` в examples:** +```hcl +provider "nubes" { + api_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm" +} +provider "sless" { + nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1" +} +``` + +--- + +## 2026-03-17 — terraform apply только на удалённом сервере + +**Решение:** `terraform init/plan/apply/destroy` для `examples/` — исключительно через SSH на сервере `naeel@5.172.178.213`. Локальный запуск запрещён. + +**Причина:** +1. Провайдер `terra.k8c.ru/naeel/sless` кэширован только на удалённом сервере +2. Локальный terraform не имеет сетевого доступа к k8s кластеру и внутренним кластерным адресам (например PGHOST вида `*.svc.cluster.local`) +3. Случайный локальный запуск с prod токенами может затронуть боевую среду + +**Как запускать:** +```bash +# Сначала синхронизировать изменения: +rsync -av -e "ssh -i /home/naeel/remote_dev/common/id_ed25519.txt" \ + /home/naeel/remote_dev/sless/examples// \ + naeel@5.172.178.213:/home/naeel/terra/sless/examples// + +# Затем запускать на remote: +ssh -i /home/naeel/remote_dev/common/id_ed25519.txt naeel@5.172.178.213 \ + 'cd /home/naeel/terra/sless/examples/ && terraform apply -auto-approve -no-color' +``` + +--- + +## 2026-03-06 — Отдельная репа для сервиса + +**Решение:** Serverless service в отдельной репе, не вместе с Terraform provider. + +**Причина:** Разные зоны ответственности, разные релизы, потенциально разные команды. + +--- + +## 2026-03-06 — Один бинарник для v1 + +**Решение:** Один Go бинарник вместо микросервисов. + +**Причина:** Нагрузки изначально нет. Проще деплоить, проще отлаживать. Разделим при необходимости. + +--- + +## 2026-03-06 — Аутентификация через облачный токен + +**Решение:** Использовать Bearer token облака, без Keycloak. + +**Причина:** Terraform provider уже работает с токенами облака. Keycloak — лишняя зависимость для v1. + +--- + +## 2026-03-06 — S3 облачный, остальное в кубере + +**Решение:** S3 (Ceph) использовать облачный (`ceph.tst.nubes.ru`), PostgreSQL/Redis — в кластере. + +**Причина:** S3 имеет внешний доступ и уже готов. Для PostgreSQL/Redis сетевого связывания с облаком пока нет — настраивается через devops облака. + +--- + +## 2026-03-06 — Текущий кластер для разработки + +**Решение:** Использовать существующий k8s кластер (namespace `sless`), потом перенести на новый. + +**Причина:** Новый кластер ещё не готов. Изоляция через namespace — безопасно для существующих сервисов. + +--- + +## 2026-03-06 — RabbitMQ откладываем + +**Решение:** В v1 только HTTP и Cron триггеры. RabbitMQ/event triggers — в v2. + +**Причина:** Упрощение первой итерации. + +--- + +## 2026-03-07 — DockerHub вместо внутреннего registry + +**Решение:** Образы функций и runtime базовые образы публикуются на DockerHub (user `naeel`). + +**Причина:** Namespace `registry` в кластере — это Apache NiFi Registry (NOT Docker). Отдельный Docker registry не поднят. DockerHub доступен и достаточен для разработки. + +--- + +## 2026-03-07 — Terraform провайдер sless — отдельный модуль в той же репе + +**Решение:** `terraform/provider/` — независимый Go-модуль внутри репы `sless`. + +**Причина:** Удобно держать рядом с кодом оператора во время разработки. + +**Важно:** `provider "sless"` и `provider "nubes"` — это **два отдельных независимых провайдера**. Объединять их нельзя: +- разные зоны ответственности (`nubes` — облачная инфраструктура, `sless` — serverless функции) +- разные релизные циклы +- разные команды в будущем + +Пользователь использует оба провайдера вместе в одном `.tf` файле, но это не означает что они должны быть одним бинарником. + +--- + +## 2026-03-07 — WaitReady в Terraform провайдере при создании функции + +**Решение:** После `UploadCode` провайдер ждёт `phase=Ready` (polling каждые 5 сек, таймаут 5 мин). + +**Причина:** Kaniko-сборка занимает ~1 минуту. Без ожидания `terraform apply` завершился бы с `phase=Building` в state, что неверно отображало бы реальное состояние ресурса. + +--- + +## 2026-03-07 — code_hash для детектирования изменений кода функции + +**Решение:** Атрибут `code_hash` в `sless_function` — пользователь задаёт через `filemd5("./handler.zip")`. Изменение hash → провайдер перезагружает zip и запускает пересборку. + +**Причина:** Terraform не отслеживает содержимое файлов автоматически. Это стандартный паттерн (аналогично `aws_lambda_function.source_code_hash`). + +--- + +## 2026-03-07 — Scale-to-zero откладываем до v2 + +**Решение:** В v1 функции работают как Deployment с постоянно живым подом (always-on). Scale-to-zero — в v2 через KEDA HTTP Add-on. + +**Причина:** Scale-to-zero меняет архитектуру контроллера и routing. Для MVP это несоразмерная сложность. Пользователь может управлять ресурсами вручную через `replicas = 0/1/N` (планируется в v1.1). + +**v2 план:** Заменить Deployment на `HTTPScaledObject` (KEDA), минимальные реплики = 0. KEDA буферизует запросы во время cold start (~1-3 сек). + +--- + +## 2026-03-07 — replicas как ручное управление масштабом (TODO v1.1) + +**Решение:** Добавить поле `replicas *int32` в `FunctionSpec`. Пользователь задаёт через Terraform: `replicas = 0` (выключить), `replicas = 1` (включить), `replicas = N` (масштабировать). + +**Причина:** Без этого функция жрёт ресурсы 24/7 даже если не нужна. Это минимальный механизм контроля потребления до реализации scale-to-zero. + +--- + +## 2026-03-07 — PostgreSQL опционален для базового Function Hosting + +**Решение:** Postgres нужен только для логов вызовов (`invocations`). Для базового деплоя функций — не нужен. Оператор работает без него (просто не пишет логи). + +**Минимальные зависимости для production:** k8s кластер + S3 + Docker registry + Ingress. + +## 2026-03-07 — Версионированные теги для runtime образов (не :latest) + +**Решение:** Runtime базовые образы (`sless-runtime-python3.11`, `sless-runtime-nodejs20`) и образ оператора (`sless-operator`) тегируются по схеме `v..`. `:latest` не используется. + +**Причина:** +- `:latest` приводит к непредсказуемому поведению: kaniko может взять старый кешированный образ, pod не перезапускается если `imagePullPolicy: IfNotPresent`. +- Версионированные теги дают явный контроль: при изменении runtime нужно обновить тег в `upload.go` → это принудительно пересобирает все функции с новым базовым образом. +- Аудит и откат: можно пинить конкретную версию runtime. + +**Соглашение:** +- Runtime образы: `naeel/sless-runtime-{lang}:v{версия}` (например `v0.1.0`) +- Оператор: `naeel/sless-operator:v{версия}` +- При изменении runtime — инкрементировать минорную версию образа и обновить константу в `upload.go` + +--- + +## 2026-03-07 — nodejs20 как второй поддерживаемый runtime + +**Решение:** Добавлен nodejs20 runtime (`node:20-alpine` base, `server.js` HTTP wrapper, `exports.handle(event)`). + +**Причина:** Node.js — стандарт для serverless (AWS Lambda, Vercel). Покрывает JS/TypeScript аудиторию. Паттерн идентичен python3.11: runtime image → kaniko → Deployment. + +**Детали реализации:** +- `runtimes/nodejs20/server.js` — `http.createServer`, динамический `require(HANDLER_PATH)` +- Зависимости через `package.json` → `npm install --omit=dev` (аналог `requirements.txt` → `pip install`) +- `entrypoint` в HCL игнорируется для Node.js (всегда `handler.js` + `exports.handle`) — TODO: поддержать произвольный entrypoint в v1.1 + +--- + +## 2026-03-07 — FunctionJob CRD: одноразовые запуски функций + +**Решение:** Добавлен `FunctionJob` CRD для одноразового запуска функции с произвольным JSON-событием. +**Причина:** Нужны sync-вызовы без HTTP — для батч-обработки, миграций, крон-задач через Terraform. +**Реализация:** +- `api/v1alpha1/job_types.go` — CRD: `FunctionRef`, `EventJSON`, phases: Pending/Running/Succeeded/Failed +- `controllers/functionjob_controller.go` — создаёт k8s Job, ждёт завершения, синхронизирует статус +- `internal/api/handler/jobs.go` — REST: CreateJob/GetJob/DeleteJob +- `terraform/provider/internal/resources/job_resource.go` — ресурс `sless_job` +- Настраиваемые таймауты: `build_timeout_sec` (sless_function), `wait_timeout_sec` (sless_job) + +--- + +## 2026-03-07 — Прокси /fn/ вместо wildcard Ingress + +**Проблема:** wildcard DNS `*.fn.kube5s.ru` недоступен (провайдер не позволяет). +**Решение:** HTTP-прокси внутри оператора — маршрут `GET|POST|... /fn/{namespace}/{name}` на `sless-api.kube5s.ru`. +**Реализация:** +- `internal/api/handler/invoke.go` — форвардит запрос к `http://{fn}.sless-fn-{ns}.svc.cluster.local:8080` +- `internal/api/router.go` — `/fn/` регистрируется **до** auth middleware, публично доступен; `/v1/` — по-прежнему с Bearer токеном (gorilla `Use()`) +- `internal/config/config.go` — новое поле `ExternalURL` (env `EXTERNAL_URL`) +- `controllers/trigger_controller.go` — если `ExternalURL` задан, `Trigger.Status.URL = ExternalURL/fn/{ns}/{name}`; иначе fallback: создаёт Ingress с поддоменом (прежнее поведение) +- `deployments/k8s/operator.yaml` — `EXTERNAL_URL=https://sless-api.kube5s.ru` + +**URL функции:** `https://sless-api.kube5s.ru/fn/{namespace}/{name}` +**E2E:** `curl https://sless-api.kube5s.ru/fn/default/hello-node` → `{"message":"Hello, Naeel! (nodejs20)"}` + +--- + +## 2026-03-08 — Lifecycle control: trigger.enabled + job.run_id + +**Задача:** управление жизненным циклом ресурсов без удаления. + +### trigger.enabled + +**Проблема:** нет способа "заморозить" функцию без удаления Trigger/Function ( +освобождение ресурсов под праздники, дебаггинг и т.д.). + +**Решение:** `enabled bool` (по умолчанию `true`) в `TriggerSpec`. +- `enabled=false` → trigger_controller масштабирует Deployment функции до 0 реплик. +- Функция не принимает запросы, не потребляет CPU (pod не запущен). +- Изменение **не** пересоздаёт ресурс (нет RequiresReplace) — in-place через PATCH. + +**Реализация:** +- `api/v1alpha1/trigger_types.go` — `Enabled bool` в TriggerSpec, `//+kubebuilder:default=true` +- `controllers/trigger_controller.go` — патчит Deployment replicas=0/1 в зависимости от Enabled +- `internal/api/handler/triggers.go` — `UpdateTrigger` handler (PATCH), поле `enabled` в request/response +- `internal/api/router.go` — `PATCH /v1/namespaces/{namespace}/triggers/{name}` +- `internal/client/client.go` — `TriggerUpdateRequest`, `UpdateTrigger()` метод +- `terraform/provider/internal/resources/trigger_resource.go` — атрибут `enabled` (Optional+Computed, default=true), реализован `Update` метод + +### job.run_id + +**Проблема:** нет способа создать FunctionJob "отложенным" — с явным контролем когда запускать. +Также нет механизма повторного запуска с сохранением структуры ресурса. + +**Решение:** `run_id int64` (по умолчанию `0`) в `FunctionJobSpec`. +- `run_id=0` → FunctionJob создаётся в k8s, но k8s Job не запускается (phase=Skipped). +- `run_id>0` → запускает Job. Увеличение значения (1→2→3) = повторный запуск через пересоздание. + +**Реализация:** +- `api/v1alpha1/job_types.go` — `RunID int64` в FunctionJobSpec, `//+kubebuilder:default=0` +- `controllers/functionjob_controller.go` — если RunID==0 → устанавливает phase=Skipped, return +- `internal/api/handler/jobs.go` — поле `run_id` в jobRequest/jobResponse +- `internal/client/client.go` — `RunID int64` в JobRequest/JobResponse +- `terraform/provider/internal/resources/job_resource.go` — атрибут `run_id` (RequiresReplace, default=0). Если run_id=0 → не ждёт завершения, phase=Skipped сразу в state. + +**Версии:** +- operator: `naeel/sless-operator:v0.1.6` +- provider: `terra.k8c.ru/naeel/sless v0.1.4` + + +--- + +## 2026-03-08 — Переключение registry с Harbor на DockerHub + +**Проблема:** Harbor (`pearlharbor.registryk8s.services.ngcloud.ru`) — внешний сервис облачного провайдера. Нестабилен: `/v2/` периодически зависает на 10+ секунд или возвращает 504. Kaniko не мог завершить push образа. + +**Решение:** `REGISTRY_HOST=naeel` (DockerHub namespace). Образы функций пушатся как `naeel/sless-default-{namespace}-{name}:latest`. + +**Реализация:** +- `deployments/k8s/operator.yaml` — configmap `REGISTRY_HOST: "naeel"` +- Secret `sless-registry-auth` уже содержал DockerHub credentials → дополнительных изменений не потребовалось + +**Компромисс:** DockerHub — публичный registry. Образы функций пользователей публично видимы. Для production нужен приватный registry (Harbor, ECR, GCR и т.д.). + +**Версии:** +- operator: оператор не пересобирался, только configmap +- commit: `b69f795` + +--- + +## 2026-03-08 — FunctionJob polling вместо Owns watch + +**Проблема:** `Owns(&batchv1.Job{})` в `SetupWithManager` не работает cross-namespace. Job создаётся в `sless-fn-{ns}`, FunctionJob — в user namespace. Watch никогда не срабатывал. + +**Решение:** Убрать `Owns`. В `syncJobStatus` при Running статусе возвращать `ctrl.Result{RequeueAfter: 5 * time.Second}` — контроллер сам поллит k8s Job каждые 5 сек. + +**Версии:** +- operator: `naeel/sless-operator:v0.1.10` +- commit: `461ac09` + +--- + +## 2026-03-08 — code_hash: filesha256 вместо output_md5 + +**Проблема:** `hashicorp/archive v2.7.x` имеет баг: `output_md5` возвращает MD5 предыдущей версии zip. `output_sha` и `output_sha256` обновляются корректно. + +**Решение:** `code_hash = filesha256("${path.module}/code/handler.js")` — хэшируется исходный файл напрямую. + +**Правило проекта:** В `sless_function.code_hash` всегда использовать `filesha256(source_file)`, не `archive_file.output_md5`. + + + +--- + +## 2026-03-08 — Rollout restart после kaniko build (imagePullPolicy + :latest) + +**Проблема:** После успешной kaniko сборки pod не перезапускался — kubelet брал кешированный образ `:latest` (imagePullPolicy: IfNotPresent). Функция возвращала старый код. + +**Решение:** В `ensureDeployment` при обновлении существующего Deployment проставляем аннотацию: +```go +existing.Spec.Template.Annotations["kubectl.kubernetes.io/restartedAt"] = fn.Status.LastBuiltAt.Time.Format(time.RFC3339) +``` +Значение привязано к `fn.Status.LastBuiltAt` → меняется при каждой сборке → Kubernetes делает rolling restart → свежий образ гарантированно пул-ится. + +**Правило проекта:** При использовании `:latest` tag всегда явно проставлять `restartedAt` annotation при обновлении кода. + +**Версия:** operator `naeel/sless-operator:v0.1.11` + +--- + +## 2026-03-10 — LLM-валидация кода при upload (pre-build security gate) + +**Решение:** Интегрировать вызов облачного LLM в pipeline upload кода. LLM анализирует исходники пользователя **до** отправки в S3 и запуска kaniko. Если код подозрительный — upload отклоняется с HTTP 400 и причиной. + +**Причина:** +1. LLM уже развёрнут (или скоро будет) в облаке nubes.ru — его нужно загрузить реальной работой. +2. Dogfooding: облачный провайдер использует собственный сервис ИИ в своём же продукте serverless. +3. Маркетинг: "ваш код проверяется ИИ перед деплоем" — реальная продающая фича. +4. Security: защита от криптомайнеров, ботнетов, DDoS-агентов, port scanners в пользовательских функциях. + +**Точка интеграции:** `internal/api/handler/upload.go` — между распаковкой zip и упаковкой tar.gz. + +**Pipeline с LLM:** +``` +POST /upload (zip) + → распаковка zip + → извлечение текстовых файлов (.py, .js, .ts, .json, .sh, .sql ...) + → POST к облачному LLM API с исходниками + системным промптом + → safe=true → Dockerfile + tar.gz → S3 → CRD patch (обычный путь) + → safe=false → HTTP 400 {"error": "code validation failed: "} + → LLM error → warning в лог, upload продолжается (soft-fail) +``` + +**Режим работы: blocking + soft-fail** +- `safe=false` → upload отклоняется (HTTP 400), код не попадает в S3, сборка не начинается. +- LLM недоступен (timeout, 5xx) → upload **пропускается** (soft-fail), логируется warning. + Причина: недоступность LLM не должна ломать весь pipeline деплоя. + +**Новый пакет:** `internal/validator/` + +**Интерфейс:** +```go +// internal/validator/validator.go +type CodeValidator interface { + // Validate проверяет код функции перед сборкой. + // files — map[filename]content (текстовые файлы из zip). + // Возвращает (true, "") если код safe, (false, reason) если нет. + // При ошибке связи с LLM — возвращает (true, "") + логирует warning (soft-fail). + Validate(ctx context.Context, files map[string]string, runtime string) (safe bool, reason string, err error) +} +``` + +**LLM-реализация:** +```go +// internal/validator/llm.go +type LLMValidator struct { + endpoint string // URL облачного LLM API (OpenAI-compatible) + apiKey string // токен доступа + timeout time.Duration // default: 15s + log *slog.Logger +} +``` + +**Конфигурация (env vars):** +| Переменная | Default | Описание | +|------------|---------|----------| +| `LLM_ENABLED` | `false` | Включатель. false → NoopValidator (всегда safe) | +| `LLM_ENDPOINT` | — | URL LLM API, например `https://llm.nubes.ru/v1/chat/completions` | +| `LLM_API_KEY` | — | Bearer-токен для LLM API | +| `LLM_TIMEOUT` | `15s` | Максимальное время ожидания ответа | + +`LLM_ENABLED=false` → оператор работает без LLM зависимости. По умолчанию выключено. + +**Prompt-стратегия:** + +Промпт НЕ хардкодится в Go — выносится в константу с возможностью override через ConfigMap. + +``` +You are a security reviewer for a serverless cloud platform. +Analyze the following {runtime} code deployed as a cloud function. + +Check for: +1. Cryptocurrency mining (crypto hash algorithms, pool connections, stratum protocol) +2. DDoS/botnet behavior (mass outbound HTTP/UDP, connection floods) +3. Port scanning / network reconnaissance +4. Reverse shells, backdoors, C2 communication +5. Attempts to escape container (access host filesystem, /proc, /sys) +6. Obfuscated code designed to hide malicious intent + +Files: +{files_content} + +Respond ONLY with valid JSON, no other text: +{"safe": true} or {"safe": false, "reason": "brief explanation"} +``` + +**Что НЕ проверяем через LLM (не его задача):** +- Качество кода, стиль, best practices +- Уязвимости в зависимостях (это Trivy/npm audit, потом) +- Бизнес-логику пользователя + +**Ограничения по размеру:** +- Суммарный размер текстовых файлов > 100KB → skip LLM (дорого, context window). Деплой проходит. +- Бинарные файлы (.pyc, .so, node_modules/) → не отправляются в LLM. +- Только расширения: `.py`, `.js`, `.ts`, `.json`, `.yaml`, `.yml`, `.txt`, `.sh`, `.sql`, `.go`. + +**Встраивание в upload.go:** +```go +// После распаковки zip, до generateDockerfile +if h.Validator != nil { + files := extractTextFiles(zipData) + safe, reason, err := h.Validator.Validate(r.Context(), files, fn.Spec.Runtime) + if err != nil { + h.Log.Warn("llm validation error (soft-fail)", "err", err) + } else if !safe { + writeJSON(w, http.StatusBadRequest, errResp("code validation failed: "+reason)) + return + } +} +``` + +**Terraform provider:** Получит `status 400: code validation failed: ` — пользователь видит причину в `terraform apply` output. + +**Файлы для реализации:** +1. `internal/validator/validator.go` — интерфейс CodeValidator + NoopValidator +2. `internal/validator/llm.go` — LLMValidator с HTTP client к OpenAI-compatible API +3. `internal/validator/extract.go` — extractTextFiles: zip → map[string]string +4. `internal/config/config.go` — добавить LLM_ENABLED, LLM_ENDPOINT, LLM_API_KEY, LLM_TIMEOUT +5. `internal/api/handler/handler.go` — добавить Validator поле +6. `internal/api/handler/upload.go` — вызов Validator между zip и tar.gz +7. `main.go` — wire: if LLM_ENABLED → LLMValidator, else → NoopValidator + +**Компромиссы:** +- +5-15 секунд к каждому деплою (зависит от скорости LLM). +- False positives: пользователь получит 400 с причиной, может обратиться в support. +- Soft-fail при недоступности LLM: security degraded, но деплой работает. +- Prompt не идеален: LLM не ловит всё. Это дополнительный слой, не единственный. + +--- + +## 2026-03-11 — Два провайдера: sless и nubes — нельзя объединять + +**Решение:** Провайдеры `sless` и `nubes` — **два отдельных независимых провайдера**. +Объединять их в один бинарник нельзя. + +**Причина:** +- Разные зоны ответственности: `nubes` — облачная инфраструктура (ВМ, сети, объектное хранилище), + `sless` — serverless функции. +- Разные релизные циклы. +- В будущем — разные команды. + +Пользователь использует оба в одном `.tf` файле — это нормально, это не значит что они один бинарник. + +--- + +## 2026-03-11 — Namespace-per-user через JWT sub → SHA256 + +**Решение:** Каждый пользователь облака получает отдельный k8s namespace. +Namespace вычисляется детерминированно из JWT sub. + +**Алгоритм:** +``` +namespace = "sless-" + hex(SHA256(JWT.sub)[:8]) +``` +Итоговая длина: 22 символа. Пример: `sless-cdd874dfa31ba6ca`. + +**Почему SHA256, а не UUID напрямую:** +- UUID (sub) напрямую в имени namespace — раскрывает внутренний ID пользователя. +- SHA256 — необратим, namespace не позволяет восстановить sub. + +**Реализация:** +- `client.SubFromJWT(token)` — декодирует JWT payload → возвращает sub +- `client.NamespaceFromSub(sub)` — SHA256(sub)[:8] → hex → "sless-{hex16}" +- Вычисляется в `provider.Configure()` до создания Client + +--- + +## 2026-03-11 — EnsureNamespace как отдельный endpoint (SoC) + +**Проблема:** Создание namespace было в resource-хендлерах (CreateFunction, CreateTrigger, CreateJob). +Это нарушение разделения ответственностей: ресурс должен заниматься только тем, для чего предназначен. + +**Решение:** +- Создан отдельный endpoint `POST /v1/namespaces/{namespace}/ensure` +- Хендлер вынесен в отдельный файл `internal/api/handler/namespace.go` +- Провайдер вызывает его **один раз** в `Configure()` до создания любых ресурсов +- `handler.go` очищен от k8s-типов (corev1, k8serrors, metav1) — только инфраструктура + +**Поведение endpoint:** +- 200 OK `{"namespace": "...", "status": "exists"}` — namespace уже был +- 201 Created `{"namespace": "...", "status": "created"}` — namespace создан +- Идемпотентен: параллельные запросы не падают (IsAlreadyExists обработан) + +**Кто отвечает за namespace:** +Только `EnsureNamespace`. Ни один другой хендлер namespace не трогает. + +--- + +## 2026-03-11 — JWT validation в операторе вместо статического токена + +**Проблема:** Оператор сравнивал Bearer токен со статическим `apiToken` из конфига. +JWT-токены облака не совпадали → все запросы от провайдера отклонялись с 401. + +**Решение:** `internal/api/middleware/auth.go` — заменена проверка: +- Было: `token == cfg.APIToken` (строковое сравнение) +- Стало: `validateJWT(token)` — проверяет структуру JWT (3 части), наличие `sub`, срок действия `exp` + +**Почему подпись не проверяется:** +Оператор находится за Ingress в закрытом кластере (trusted perimeter). +Проверка подписи требует публичный ключ issuer — усложнение без реальной пользы в данной топологии. +Подпись проверяется косвенно через `PingNubesAPI` в провайдере при `terraform init`. + +**Версия:** operator v0.1.20 + +--- + +## 2026-03-11 — Валидация токена через nubes API при Configure + +**Решение:** При `terraform init` / `terraform apply` провайдер пингует nubes API +для подтверждения что токен действителен. + +**Реализация:** `client.PingNubesAPI(ctx, endpoint, token)`: +- `GET ` с Bearer токеном +- 401/403 → токен отклонён → ошибка инициализации провайдера +- Ошибка соединения → ошибка инициализации +- Любой другой статус (200, 404, 500...) → токен не декларирован невалидным → OK + +**Конфигурация:** +```hcl +provider "sless" { + endpoint = "https://sless-api.kube5s.ru" + token = file("./secrets/prod.token") + nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1" +} +``` +Env-альтернативы: SLESS_ENDPOINT, SLESS_API_TOKEN, NUBES_ENDPOINT. + +--- + +## 2026-03-11 — SoC рефакторинг handler.go + +**Решение:** Файл `handler.go` — чистая инфраструктура. +Бизнес-логика по доменам — в отдельных файлах одного package. + +**Структура handler/ package:** +``` +handler.go — Handler struct + helpers (writeJSON, errResp, pathVar, namespace) +namespace.go — EnsureNamespace (k8s namespace lifecycle) +functions.go — CRUD Function +triggers.go — CRUD Trigger +jobs.go — CRUD FunctionJob +upload.go — zip -> tar.gz -> S3 -> CRD patch +invoke.go — прокси /fn/ -> in-cluster +invocations.go — 501 stub +``` + +**Принцип:** каждый файл отвечает за один домен. +`handler.go` не импортирует `corev1/k8serrors/metav1` — эти зависимости только в `namespace.go`. + +--- + +## 2026-03-11 — Namespace пользователя никогда не удаляется + +**Решение:** User namespace (`sless-{hex16}`) **не удаляется** ни при каких обстоятельствах. + +**Причина:** +- Namespace вычисляется из `JWT.sub` — неизменяемого идентификатора пользователя. +- Namespace = "home directory" пользователя в кластере: `terraform destroy` удаляет + функции/триггеры/джобы, но не сам контейнер для ресурсов. +- Удаление namespace уничтожило бы все CRD объекты пользователя. +- Повторный `terraform apply` (после destroy) нашёл бы свой ns живым — правильное поведение. + +**Верификация (проверено):** +- В API нет маршрута `DELETE /v1/namespaces/{namespace}`. +- `handleDeletion` в `function_controller.go` удаляет: Deployment, Service, Ingress, kaniko Job. +- `handleTriggerDeletion` в `trigger_controller.go` удаляет: CronJob (в deployNS), Service, Ingress. +- Оба контроллера содержат явный комментарий: "Namespace sless-fn-{userNS} НЕ удаляется — он принадлежит пользователю". +- Тест: `kubectl get ns sless-cdd874dfa31ba6ca` — namespace жив через 93 минуты после `terraform destroy`. + +**Оба namespace предохраняются:** +- `sless-{hex16}` — user namespace (хранит CRD объекты Function/Trigger/FunctionJob) +- `sless-fn-{hex16}` — deploy namespace (хранит Deployment/Service/Ingress/CronJob) + +--- + +## 2026-03-11 — Builder SoC: context.go отделён от upload.go + +**Проблема:** `generateDockerfile`, `runtimeBaseImage`, `zipToTarGz` жили в `handler/upload.go`. +Знание о runtime образах и структуре build context — детали **сборки**, не HTTP-хендлера. +Нарушение SoC: HTTP-файл знал о Docker, kaniko, tar.gz, zip-разборе. + +**Решение:** Перенести в `internal/builder/context.go`, единственный публичный API: +```go +func PrepareContext(zipData []byte, runtime string) (*bytes.Buffer, error) +``` + +**Результат:** +- `upload.go`: ~200 LOC → ~60 LOC (только HTTP: принять zip, вызвать PrepareContext, сохранить в S3) +- `context.go`: всё знание о runtime образах, zip→tar, Dockerfile генерации + +**Детали реализации:** +- `zipToTarGz` принимает `*zip.Reader` вместо `[]byte` — zip парсится один раз в `PrepareContext` +- `PrepareContext` сама сканирует zip-архив (requirements.txt, package.json) — хендлер не знает об этом +- `runtimeBaseImage` возвращает ошибку для неизвестного runtime — ранний fail до kaniko + +**Тесты:** 4 теста в `internal/builder/context_test.go` (python+requirements, node без package.json, unsupported runtime, Dockerfile-first в tar). + +--- + +## 2026-03-11 — Фильтрация hop-by-hop headers в /fn/ прокси + +**Проблема:** `invoke.go` пробрасывал все заголовки ответа функции клиенту, включая hop-by-hop. +`Transfer-Encoding: chunked` особенно опасен: Go `http.ResponseWriter` не умеет его воспроизводить, +клиент получал некорректное тело ответа (или ошибку framing). + +**Решение:** Фильтровать по RFC 2616 §13.5.1 перед записью в `w`: +```go +var hopByHopHeaders = map[string]bool{ + "Connection": true, "Keep-Alive": true, "Proxy-Authenticate": true, + "Proxy-Authorization": true, "Te": true, "Trailers": true, + "Transfer-Encoding": true, "Upgrade": true, +} +// В цикле: +if hopByHopHeaders[k] { continue } +``` + +**Почему map[string]bool:** O(1) lookup, ключи в canonical form (`http.CanonicalHeaderKey`), +совпадает с форматом ключей в `http.Header` — нет нужды нормализовывать. + +**Тесты:** 3 теста в `internal/api/handler/invoke_test.go` +(filtered from response, map contains all RFC2616, canonical key form). + +--- + +## 2026-03-11 — JWKS insertion point stub в auth.go + +**Контекст:** v1 auth — `validateJWT` проверяет структуру токена (sub, exp) без проверки подписи. +Это допустимо в trusted perimeter (оператор в k8s, доступен только изнутри). + +**Решение:** Добавлена `verifySignature()` как закомментированная заготовка в `auth.go`. + +**v2 план (когда nubes даст JWKS endpoint):** +1. `GET {NUBES_JWKS_URL}/.well-known/jwks.json` +2. Найти ключ по `kid` из JWT header +3. Проверить подпись RS256/ES256 через `github.com/lestrrat-go/jwx/v2` +4. Добавить вызов `verifySignature(token)` в `validateJWT` после проверки структуры. + +**Зачем stub:** любой агент или разработчик видит точную строку для вставки. Нет риска забыть. + +--- + +## 2026-03-11 — CronJob перенесён в deployNS + +**Проблема:** CronJob для HTTP-триггеров создавался в `tr.Namespace` (user namespace: `sless-{hex16}`). +При применении NetworkPolicy (каждый namespace изолирован) — CronJob не мог бы дотянуться до API. + +**Решение:** CronJob создаётся в `deployNS` = `"sless-fn-" + tr.Namespace`, +где живут Deployment/Service — NetworkPolicy там уже правильная. + +**Затронутые места в trigger_controller.go:** +- `buildCronJob` — namespace в ObjectMeta +- `r.Client.Create` — нет изменений (namespace из объекта) +- `r.Client.Get` в reconcile — `deployNS` вместо ns +- `handleTriggerDeletion` — удаление CronJob из `deployNS` + +**Дополнительно:** `curlimages/curl:latest` → `curlimages/curl:8.5.0` (pin версии). + +--- + +## 2026-03-11 — Sort env vars в buildDeployment + +**Проблема:** `fn.Spec.Env` — это `map[string]string`. Итерация по map в Go недетерминирована. +Каждый reconcile мог генерировать Pod spec с другим порядком env vars → лишние rollout'ы. + +**Решение:** +```go +keys := make([]string, 0, len(fn.Spec.Env)) +for k := range fn.Spec.Env { keys = append(keys, k) } +sort.Strings(keys) +for _, k := range keys { envVars = append(envVars, corev1.EnvVar{Name: k, Value: fn.Spec.Env[k]}) } +``` + +**Тесты:** 2 теста в `controllers/function_controller_unit_test.go` +(4 env vars → алфавитный порядок после SLESS_ENTRYPOINT; пустой Env → только SLESS_ENTRYPOINT). diff --git a/doc/overview.md b/doc/overview.md new file mode 100644 index 0000000..eea641a --- /dev/null +++ b/doc/overview.md @@ -0,0 +1,204 @@ +# Архитектура системы + +Последнее обновление: 2026-03-18 (v0.1.33 + funcs-service v0.1.3) + +## Общее описание + +Managed Serverless Functions Service для облачного провайдера nubes.ru. +Пользователь загружает код через Terraform, сервис его собирает (kaniko) и запускает +по HTTP-триггеру, расписанию (cron) или вручную через one-shot Job. + +## Стек + +| Компонент | Технология | Где запущен | +|-----------|-----------|-------------| +| Operator (API + Controllers) | Go (controller-runtime) | Kubernetes, namespace `sless` | +| funcs-service (глобальная консоль) | Go (net/http) | Kubernetes, namespace `sless` | +| PostgreSQL | PostgreSQL 16 | Kubernetes, namespace `sless` | +| S3 | Ceph (облачный) | `s3.msk-1.ngcloud.ru` | +| Container Registry | DockerHub (`naeel/`) | внешний | +| Builder | kaniko (k8s Job) | namespace пользователя | +| Функции (HTTP) | k8s Deployment + Service | namespace пользователя | +| Функции (one-shot) | k8s Job | namespace пользователя | +| Функции (cron) | k8s CronJob | namespace пользователя | +| Terraform Provider | Go (plugin framework v6) | localhost/CI | +| nubes API | REST (облако) | `deck-api.ngcloud.ru` | + +> Redis и RabbitMQ — отложены до v2. + +## Компонент: funcs-service + +Глобальный HTTP сервис — **одна копия** на весь кластер, для всех пользователей. + +``` +User Browser / curl + └─► https://sless.kube5s.ru/funcs/ + └─► nginx Ingress (sless-funcs-ingress) + └─► sless-funcs-service:8090 (namespace sless) + └─► http://sless-operator.sless.svc.cluster.local:9090/v1/... +``` + +**Файлы:** +- `services/funcs/main.go` — логика +- `services/funcs/Dockerfile` — multi-stage Go → alpine +- `deployments/k8s/funcs-service.yaml` — Deployment + Service + Ingress + +**Env vars сервиса:** +| Переменная | Значение | +|-----------|---------| +| `SLESS_OPERATOR_URL` | `http://sless-operator.sless.svc.cluster.local:9090` | +| `SLESS_EXTERNAL_URL` | `https://sless.kube5s.ru` | +| `SLESS_EXCLUDE` | `event-writer,event-monitor,event-cleaner` | +| `SLESS_SERVICE_TOKEN` | JWT токен (задаётся через `kubectl set env`, не в git) | + +## Хранение кода функций в S3 + +``` +Terraform source_dir (локально) + └─► zip → POST /upload → builder.PrepareContext() + ├─► functions/{ns}/{name}/{ts}.zip ← ИСХОДНЫЙ КОД пользователя + └─► contexts/{ns}/{name}/{ts}.tar.gz ← BUILD CONTEXT для kaniko + └─► Function CRD: spec.s3Key = "contexts/..." + └─► контроллер → kaniko Job → Docker image +``` + +Для web-консоли: `GET /source` читает `functions/{ns}/{name}/{ts}.zip` напрямую. + +## Изоляция пользователей — Namespace per user + +Каждый пользователь облака получает **отдельный k8s namespace**. + +``` +JWT токен (Bearer) + └─► JWT.sub (строка "0199e325-1cdf-7cda-9319-e5302a85e291") + └─► SHA256(sub) → первые 8 байт → hex → "sless-{16 hex символов}" + └─► namespace = "sless-cdd874dfa31ba6ca" +``` + +- Namespace детерминирован: один sub → всегда один namespace. +- sub не раскрывается в имени namespace (SHA256 необратим). +- Длина 22 символа — укладывается в лимит k8s (63). + +**Кто создаёт namespace:** +Terraform провайдер при Configure() вызывает POST /v1/namespaces/{ns}/ensure +**один раз**, до любых ресурсных операций. +Resource-хендлеры (Function, Trigger, Job) namespace **не создают** — это не их ответственность. + +## Аутентификация + +### Оператор (REST API) +- Bearer JWT в заголовке Authorization +- Проверяется структура JWT (3 части), наличие sub claim, срок действия exp +- Подпись **не проверяется** — trusted perimeter (оператор за Ingress) + +### Terraform Provider (при Configure) +1. Декодирует JWT → sub +2. Вычисляет namespace через SHA256 +3. Если задан nubes_endpoint — пингует nubes API (GET ) с тем же токеном + - HTTP 401/403 → ошибка инициализации провайдера + - Недоступен → ошибка инициализации +4. Вызывает POST /v1/namespaces/{ns}/ensure (создаёт namespace если нет) + +## Схема вызова + +``` +Пользователь (curl / браузер) + | + v GET|POST|... /fn/{namespace}/{name}/* +sless-api Ingress -> Operator /fn/ прокси + | + v HTTP forward -> http://{name}.{namespace}.svc.cluster.local:8080 +k8s Service -> Deployment/Pod функции +``` + +``` +terraform apply + | + v provider Configure() + 1. JWT -> sub -> namespace + 2. PingNubesAPI (валидация токена) + 3. POST /v1/namespaces/{ns}/ensure <- создаёт k8s namespace + | + +-> POST /v1/namespaces/{ns}/functions <- создаёт Function CRD + | +-> POST /upload (zip) <- загружает код -> S3 -> kaniko Job + | +-> polling phase=Ready + | + +-> POST /v1/namespaces/{ns}/triggers <- создаёт Trigger CRD + | +-> controller: Deployment + Service + (CronJob для cron) + | + +-> POST /v1/namespaces/{ns}/jobs <- создаёт FunctionJob CRD + +-> controller: k8s Job -> result в status +``` + +## Структура кода + +``` +sless/ +|-- main.go точка входа: k8s manager + REST API сервер (goroutine) +|-- internal/ +| |-- api/ +| | |-- router.go gorilla/mux: /fn/ (публичный), /v1/ (auth + middleware) +| | |-- handler/ +| | | |-- handler.go Handler struct + helpers (writeJSON, namespace(), pathVar()) +| | | |-- namespace.go EnsureNamespace (POST /v1/namespaces/{ns}/ensure) +| | | |-- functions.go CRUD Function +| | | |-- triggers.go CRUD Trigger +| | | |-- jobs.go CRUD FunctionJob +| | | |-- upload.go zip -> Dockerfile -> tar.gz -> S3 -> CRD patch +| | | |-- invoke.go прокси /fn/{ns}/{name} -> in-cluster DNS +| | | +-- invocations.go 501 stub (реализация отложена) +| | +-- middleware/ +| | |-- auth.go JWT validation (struct + sub + exp, подпись не проверяется) +| | +-- logging.go slog request logger +| |-- builder/ +| | |-- builder.go kaniko Job lifecycle (Build, JobStatus, Cleanup) +| | +-- context.go PrepareContext: zip+runtime → tar.gz+Dockerfile для kaniko +| |-- config/config.go Load() из env vars +| +-- storage/ +| |-- postgres/store.go SaveInvocation, ListInvocations, RunMigrations +| +-- s3/client.go Upload, Download, UploadContext (tar.gz для kaniko) +|-- controllers/ +| |-- function_controller.go Reconcile: Pending->Building->Ready/Failed + Deployment +| |-- trigger_controller.go Reconcile: Service+Ingress (http) / CronJob (cron) +| +-- functionjob_controller.go Reconcile: k8s Job -> Succeeded/Failed + output capture +|-- api/v1alpha1/ +| |-- function_types.go Function CRD +| |-- trigger_types.go Trigger CRD +| +-- job_types.go FunctionJob CRD +|-- deployments/k8s/ +| |-- operator.yaml Deployment + Service + Ingress +| +-- rbac.yaml ClusterRole + ClusterRoleBinding + ServiceAccount +|-- terraform/provider/ независимый Go-модуль +| +-- internal/ +| |-- client/client.go SubFromJWT, NamespaceFromSub, PingNubesAPI + CRUD +| |-- provider/provider.go Configure(): JWT->NS->ping->EnsureNamespace +| +-- resources/ +| |-- function_resource.go sless_function +| |-- trigger_resource.go sless_trigger +| +-- job_resource.go sless_job ++-- runtimes/ + |-- python3.11/ server.py + Dockerfile -> naeel/sless-runtime-python3.11:v0.1.1 + +-- nodejs20/ server.js + Dockerfile -> naeel/sless-runtime-nodejs20:v0.1.2 +``` + +## Kubernetes кластер + +Сейчас используется существующий кластер (временный). +Планируется переезд на новый кластер — манифесты переносятся без изменений. + +Ноды: +- wheel-control-plane-fm9sr — control-plane +- wheel-workers-tv4qr-r45xs — worker +- wheel-workers-tv4qr-x8xw7 — worker + +Ingress: nginx, external IP 5.172.178.182 +API endpoint: https://sless-api.kube5s.ru + +## Версии в production + +| Артефакт | Тег/Версия | +|---------|-----------| +| naeel/sless-operator | v0.1.22 | +| terra.k8c.ru/naeel/sless провайдер | v0.1.13 | +| naeel/sless-runtime-python3.11 | v0.1.1 | +| naeel/sless-runtime-nodejs20 | v0.1.2 | diff --git a/doc/progress.md b/doc/progress.md index 6ffaa99..c04ed0e 100644 --- a/doc/progress.md +++ b/doc/progress.md @@ -1,6 +1,53 @@ # Прогресс разработки -Последнее обновление: 2026-03-18 12:00 (DNS инцидент устранён, POSTGRES E2E PASS) +Последнее обновление: 2026-03-18 20:00 (funcs global service v0.1.3, ветка feat/web-console) + +## 2026-03-18 — funcs: глобальный Go сервис + ветка web-console + +| # | Задача | Статус | Заметки | +|---|--------|--------|---------| +| 1 | Python runtime str→text/plain | ✅ | `runtimes/python3.11/server.py`: если функция возвращает `str` → `Content-Type: text/plain`. Образ `naeel/sless-runtime-python3.11:v0.1.3` | +| 2 | funcs_list.py: plain text вывод | ✅ | Переписан с JSON на plain text с `─` разделителями, фильтр `SLESS_EXCLUDE` | +| 3 | Оператор v0.1.33 | ✅ | context.go: python runtime → v0.1.3; `internal/builder/context.go` обновлён | +| 4 | `funcs` как глобальный Go сервис | ✅ | `services/funcs/main.go` — standalone Go HTTP сервер. Деплоится ОДИН РАЗ в namespace `sless`, работает для ВСЕХ пользователей. Образ `naeel/sless-funcs-service:v0.1.3` | +| 5 | URL без токена | ✅ | `https://sless.kube5s.ru/funcs/` — открывается в браузере без токена. `SLESS_SERVICE_TOKEN` задан через `kubectl set env` | +| 6 | Удалить funcs из terraform | ✅ | `examples/POSTGRES/resources.tf`: удалены `sless_function.funcs_list`, `sless_trigger.funcs_list_http`, `output "funcs_url"`, `local.user_namespace`. `terraform apply` — 2 destroyed | +| 7 | Ветка feat/web-console | ✅ | Создана от `feat/harbor-integration` на remote, запушена | +| 8 | Документация архитектуры хранения кода | ✅ | См. `doc/decisions/log.md` раздел "Хранение кода функций и web-консоль" | +| 9 | Коммиты | ✅ | `e8cd62e` (funcs Go service), `38bb494` (v0.1.3 — /funcs/namespace без токена) | + +### Текущие URL функций + +``` +https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae # список функций (без токена) +https://sless.kube5s.ru/funcs?token= # то же, через токен +https://sless.kube5s.ru/fn/sless-ffd1f598c169b0ae/pg-info +https://sless.kube5s.ru/fn/sless-ffd1f598c169b0ae/pg-table-reader +``` + +### Образы + +| Образ | Версия | Что изменилось | +|-------|--------|----------------| +| `naeel/sless-operator` | v0.1.33 | created_at, last_built_at в API; python runtime v0.1.3 | +| `naeel/sless-runtime-python3.11` | v0.1.3 | str → text/plain в server.py | +| `naeel/sless-funcs-service` | v0.1.3 | /funcs/ без токена, /health probe | + +--- + +## 2026-03-18 — Следующие шаги: web-консоль (ветка feat/web-console) + +| # | Задача | Статус | Заметки | +|---|--------|--------|---------| +| 1 | Оператор: `GET /v1/namespaces/{ns}/functions/{name}/source` | ⏳ | Читает `functions/{ns}/{name}/{ts}.zip` из S3, возвращает JSON с файлами | +| 2 | Оператор: `PATCH /v1/namespaces/{ns}/triggers/{name}` | ⏳ | Принимает `{"enabled": true/false}`, обновляет Trigger CRD | +| 3 | `sless-funcs-service`: HTML страница | ⏳ | Если `Accept: text/html` → отдаёт HTML с JS; аккордеон файлов + кнопки ▶/■ | +| 4 | Bump оператора до v0.1.34 | ⏳ | После пп. 1-2 | +| 5 | Bump funcs-service до v0.2.0 | ⏳ | После п. 3 | + +--- + +## 2026-03-18 12:00 — DNS инцидент устранён, POSTGRES E2E PASS ## 2026-03-18 — DNS инцидент: sless-api.kube5s.ru → мёртвый кластер