# Решения и обоснования --- ## 2026-04-01 — PG_TEST: lifecycle ignore_changes для vault_secrets — НЕ РАБОТАЕТ ### Попытка Добавить в `nubes_postgres` блок `lifecycle { ignore_changes = [vault_secrets] }`. ### Результат Terraform выводит предупреждение и игнорирует директиву: > "Including this attribute in ignore_changes has no effect." `vault_secrets` — `Computed`-only атрибут (выставляется только провайдером), для таких атрибутов `ignore_changes` не применимо. ### Механизм проблемы При `vault_secrets` "изменённом снаружи" Terraform обновляет `nubes_postgres` in-place, но в плане выставляет `id = (known after apply)`. Дочерние ресурсы с `postgres_id` (ссылка на `nubes_postgres.*.id`) теряют resolved value → форсированный replace. ### Текущий статус Открытая проблема. `lifecycle ignore_changes` убран из конфигурации (не помогает). Рабочий обходной путь на данный момент: запускать `apply` только на чистом state. --- ## 2026-04-01 — PG_TEST: depends_on chain вместо параллельного создания ### Решение Все ресурсы `nubes_postgres_user` и `nubes_postgres_database` создаются строго последовательно через явную цепочку `depends_on`: ``` nubes_postgres → pg_test_user → pg_test_db → extra_user1 → extra_user2 → extra_db1 → extra_db2 ``` ### Почему Nubes API (deck-api-test.ngcloud.ru) не поддерживает параллельные операции создания пользователей на одном PG-инстансе — возникает race condition на стороне Vault: конкурентные записи в один Secret дают "Секрет не был создан" / "key doesn't exist". Terraform по умолчанию выполняет независимые ресурсы параллельно (degree=10). `depends_on` — единственный способ принудить последовательность без добавления искусственных атрибутов-зависимостей. --- ## 2026-04-01 — PG_TEST: роль пользователя — только ddl_user ### Решение В конфигурациях `examples/PG_TEST` используется только роль `ddl_user` для ресурсов `nubes_postgres_user`. Роль `app_user` из конфигурации удалена. ### Почему При создании `nubes_postgres_user` с `role = "app_user"` Nubes API (версия v5.0.51) возвращает ошибку "Секрет для пользователя не был создан" после ~3 минут ожидания. Воспроизводится стабильно. Роль `ddl_user` работает корректно. Вывод: `app_user` либо не поддерживается в `nubes_postgres_user`, либо требует другой конфигурации (не задокументированной). До выяснения — только `ddl_user`. --- ## 2026-03-30 — Переход на READ-ONLY подход в тестах (v2) ### Решение Полностью переписан `vm_stress_test.sh`. Убраны все функции записи в файлы (`write_tfvars`, `backup_tfvars`, `restore_tfvars`). Переопределения переменных теперь через `-var` в terraform CLI. Добавлена проверка md5sum terraform.tfvars. ### Почему Функция `write_tfvars()` в v1 уничтожила `terraform.tfvars`, потеряв JWT-токен `api_token`. Пайплайн `grep | cut | xargs | sed` не смог корректно обработать JWT строку длиной 1200+ символов. Восстановление потребовало ручного вмешательства. ### Ключевые принципы v2: - Скрипт **НИКОГДА** не пишет в файлы (read-only) - Все переопределения — через `terraform apply -var "key=value"` - md5sum проверка после каждой фазы, аварийный стоп при изменении - `terraform.tfvars` содержит секреты (api_token) → нельзя трогать --- ## 2026-03-30 — Автономный тестовый фреймворк для VM ### Решение Внедрить bash-скрипт `vm_stress_test.sh` как основной инструмент для долгого, самовосстанавливающегося тестирования примера `examples/VM`. ### Почему - Предыдущие ручные тесты подтвердили корректность логики, но для выявления редких race conditions и обеспечения преемственности между разными сессиями агентов нужен воспроизводимый сценарий. - Скрипт инкапсулирует все "знания" о VM (IP, ключи, логика очистки `apt`), позволяя любому агенту запустить тест одной командой. - Использование `timeout` на уровне команд `terraform` и `ssh` внутри скрипта предотвращает зависание автоматизации. ## 2026-03-29 — Матрица тестов VM example подтверждена прогоном ### Решение Оставить текущую модель тестирования [examples/VM](examples/VM) как комбинацию из ручного cleanup, destroy/apply цикла, частичного отключения job-ресурсов и короткого stress loop. ### Почему - Эта матрица проверяет и lifecycle VM, и идемпотентность job-ресурсов, и реакцию на изменение количества/порядка установок. - Отдельный destroy/apply прогон подтвердил suspend/wake поведение без необходимости писать новый тестовый фреймворк. - Stress loop из двух циклов дал полезную нагрузку без чрезмерного времени прогона. ## 2026-03-29 — Матрица тестов для VM example ### Решение Для проверки поведения [examples/VM](examples/VM) использовать не один прогон, а набор сценариев: 1. обычный `apply` как базовый контроль; 2. удаление всего установленного ПО внутри ВМ перед `destroy`; 3. `destroy` с проверкой перехода ВМ в `suspend`; 4. повторный `apply` с проверкой wake-up и повторной установки; 5. изменение количества и порядка установок; 6. стресс-прогоны с несколькими повторениями. ### Почему так - Один проход не показывает идемпотентность и не ловит проблемы порядка ресурсов. - Сценарий с `destroy` проверяет, что инфраструктура не удаляет ВМ физически, а переводит её в `suspend`. - Повторный `apply` после `suspend` проверяет восстановление состояния без ручного вмешательства. - Перестановки и изменение количества установок нужны, чтобы проверить устойчивость к дрейфу и к разным графам зависимостей. --- ## 2026-03-21 — Оценка трудозатрат на проект | Компонент | Оценка | |-----------|--------| | Go operator — CRD (Function, Trigger, Job), 3 контроллера, reconcile loops, self-healing | 80-100 ч | | REST API — router, middleware, 8 handlers, namespace lifecycle | 40-50 ч | | Terraform провайдер — provider, client, 4 ресурса | 40-60 ч | | Builder — kaniko, S3 upload, context tar | 20-30 ч | | Runtimes — Go/Node/Python базовые образы | 20-30 ч | | Инфраструктура — k8s manifests, kustomize, Harbor, Postgres | 20-30 ч | | Тесты — lifecycle (47) + survival (34), ~1900 строк bash | 40-60 ч | | Документация — architecture, decisions, errors, API, handoffs | 20-30 ч | **Итого: ~280-390 человеко-часов** (7-10 недель одного разработчика в нормальном темпе). С AI-ассистентом в паре реальное живое время ~80-120 часов (30-50% от полного объёма). --- ## 2026-03-21 — timeout_sec для sless_service: 0 = нет лимита (не 30s по умолчанию) ### Контекст В `api/v1alpha1/service_types.go` поле `TimeoutSec` имело `+kubebuilder:default=30`. В `invoke.go` при `TimeoutSec == 0` был хардкод `35 * time.Second` как дефолтный клиент. Пользователь хотел ввести таймаут как **опциональный** параметр: если не указан — длинные/бесконечные функции работают без ограничений. Дефолт 30s ломал это намерение. ### Решение `TimeoutSec = 0` → «нет ограничения». Убраны все механизмы дефолтного таймаута: 1. `+kubebuilder:default=30` удалён из CRD — поле 0 по умолчанию в Go (zero value) 2. `invoke.go`: `if timeoutSec <= 0 { return &http.Client{} }` — Go Timeout=0 = нет дедлайна 3. `services.go`: валидация `< 0 || > 900` → 400 Bad Request (0 разрешён) 4. Terraform schema: убрано `Computed: true`; `svcToModel`: `0 → Int64Null()` (null в state) ### Почему именно так - **0 = нет лимита** — стандарт в Go для http.Client.Timeout (явно задокументировано в stdlib) - **null в Terraform** вместо 0 — чтобы пользователь видел "не задано", а не "0 секунд" - **Computed убрано** — поле не знает своего значения пока пользователь не задал явно; Computed означало бы "оператор сам решит" что неверно - **Диапазон 1–900** — верхний предел защищает от бесконечных зависших запросов в Production (15 минут достаточно для любой serverless задачи) ### Затронутые файлы | Файл | Что изменено | |------|-------------| | `api/v1alpha1/service_types.go` | Убран `+kubebuilder:default=30`, добавлен комментарий | | `internal/api/handler/invoke.go` | `invokeHTTPClient(0)` → `&http.Client{}` | | `internal/api/handler/services.go` | Валидация в CreateService и UpdateService | | `terraform/provider/internal/resources/service_resource.go` | Schema + svcToModel | --- ## 2026-03-21 — Объединить sless_function и sless_service в единый пользовательский листинг ### Контекст `/funcs/{namespace}` (web-консоль) показывал только `sless_function` ресурсы (Kind=Function). `sless_service` ресурсы (`pg-info`, `pg-table-reader`, `pg-table-writer`) были скрыты — пользователь не видел часть своих развёрнутых функций. Первоначальный вопрос: почему `https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae` пуст? Ответ: там были `sless_service`, а не `sless_function` — их не рендерили. ### Решение Пользователю всё равно какой тип ресурса лежит под капотом — для него это просто «функция». Объединить оба типа в один список с визуальным маркером типа: - `sless_function` → бейдж `job` - `sless_service` → бейдж `always-on` Добавить поля `Kind` и `URL` в `fnResponse`. `fetchAndRender` теперь делает два запроса: 1. `GET /v1/namespaces/{ns}/functions` — sless_function (job-style) 2. `GET /v1/namespaces/{ns}/services` — sless_service (always-on Deployment) Оба списка объединяются, фильтруются и сортируются единообразно. ### Почему НЕ делаем единый endpoint на операторе Не усложняем оператор ради UI. Агрегацию делает funcs-service — он уже служит «фасадом» между браузером и оператором. Оператор остаётся строго CRUD. ### Имплементация - `services/funcs/main.go`: `svcResponse`, объединение в `fetchAndRender` - `services/funcs/index.html`: `badge-kind-service/function`, счётчик типов - Коммит `683d728`, funcs-service `v0.2.1` --- ## 2026-03-21 — Добавить /services/{name}/source в оператор, не расширять proxySourceGet на API gateway ### Контекст После объединения листинга возникла 404 при просмотре кода `sless_service` через web-консоль. `proxySourceGet` передавал запрос на `/functions/{fn}/source`, но оператор маршрута `/services/{fn}/source` не имел. ### Варианты 1. В операторе: один универсальный `/resources/{fn}/source?type=service|function` — усложнит роутинг, нарушит REST-конвенцию. 2. В funcs-service: разветвлять URL по `kind` — но тогда funcs-service должен знать о внутренней топологии. 3. **Выбранный**: добавить отдельный `GET /v1/namespaces/{ns}/services/{name}/source` в оператор — симметрично с `/functions/{name}/source`. funcs-service передаёт `?kind` параметр. ### Почему так - Симметричность `/functions/…/source` и `/services/…/source` — интуитивный REST. - Никаких изменений в роутинге оператора — просто новый endpoint с той же логикой. - `GetServiceSource` — буквально `GetSource` с `Service` CRD вместо `Function`. 30 строк кода. - Коммит `50f2456`, оператор `v0.1.45` --- ## 2026-03-20 — Merge: убрать sless_function как обязательный prerequisite для sless_job ### Контекст `sless_job` ранее требовал `FunctionRef` — имя существующего `sless_function` из которого брался `ImageRef`. Это создавало два отдельных ресурса для одной задачи (запустить код один раз): ```hcl resource "sless_function" "f" { ... } # build resource "sless_job" "j" { function = sless_function.f.name ... } # run ``` ### Решение Сделать `FunctionJobSpec` самодостаточным: встроить `Runtime/Entrypoint/Env/S3Key` и запускать kaniko сборку непосредственно из FunctionJob-контроллера (новая фаза `Building`). ```hcl resource "sless_job" "j" { runtime = "python3.11" source_dir = "./code/fn" ... } ``` ### Почему НЕ удаляем sless_function `sless_function` нужен для `sless_trigger` (type=cron/http) — они ссылаются на функцию. Для триггеров образ должен жить вечно (не удаляться после запуска), и за ним следит Function CRD. `sless_job` же — разовый запуск; после завершения Job удаляется, образ остаётся в registry. ### Изменения в State Machine FunctionJobReconciler ``` Было: Pending → (ждать Function.Ready) → Running → Succeeded/Failed Стало: Pending → Building (kaniko) → Pending + ImageRef → Running → Succeeded/Failed ``` Фаза `Building` охраняется аннотацией `sless.kube5s.ru/build-job` — идемпотентна при рестарте. --- ## 2026-03-19 — Go runtime v0.1.1: внешние зависимости через go.mod/go.sum ### Контекст Go runtime `naeel/sless-runtime-go1.23:v0.1.0` содержал `go.mod` только с `module sless/fn` и `go 1.23`. Никаких `require` — пользовательский код мог использовать только stdlib. При попытке добавить `pgxpool` в handler.go функция не собиралась (зависимость не найдена). ### Решение Добавить `require github.com/jackc/pgx/v5 v5.7.2` в `runtimes/go1.23/go.mod`. Сгенерировать `go.sum` через `go mod tidy` (stub `.go` файл с импортом нужен — иначе tidy удалит deps). Обновить `Dockerfile` — добавить `COPY go.sum` + `RUN go mod download` **до** копирования пользовательского кода → зависимости кешируются в слое Docker, не скачиваются при каждой сборке функции. ### Почему pgx/v5, а не lib/pq - `pgx/v5` — современный нативный PG-драйвер, `pgxpool` встроен, не нужен отдельный `database/sql` - `lib/pq` — legacy, минимальный API, отсутствует connection pool - `jackc/pgx/v5 v5.7.2` — последний стабильный тег на момент решения ### Что стало возможным Любая Go функция в платформе может импортировать `pgxpool` и работать с PG напрямую: ```go import "github.com/jackc/pgx/v5/pgxpool" ``` ### Версионирование образа `v0.1.0` → `v0.1.1` — изменение breaking: бинарник пересобирается с новыми deps. Base image в `context.go` обновляется с `v0.1.0` на `v0.1.1`, оператор бампится. --- ## 2026-03-19 — Архитектура event-trigger (Вариант A: отдельный event-dispatcher) ### Контекст До этого event-monitor/writer/cleaner работали как пользовательские sless-функции в namespace юзера — это неправильно: они ходили в операторскую Postgres напрямую, создавали таблицы без миграций, зависели от self-hosted rabbitmq. Всё это удалено из кластера (audit 2026-03-19). ### Варианты которые рассматривались **Вариант A: отдельный event-dispatcher сервис** ← ВЫБРАН **Вариант B: dispatcher встроен горутиной в оператор** **Вариант C: CronJob polling из очереди** ### Решение: Вариант A **Почему не B:** AMQP-соединения внутри operator reconciler усложняют lifecycle и тестирование. Падение AMQP затронет весь оператор. **Почему не C:** polling — не realtime, не масштабируется, неловкий ACK. **Почему A:** чистое разделение ответственности. Оператор управляет CRD, dispatcher управляет AMQP. Независимые restart/deploy. Легко тестировать отдельно. ### Поток данных ``` Пользователь: kubectl apply — Trigger{type:event, queue:"orders", functionRef:"my-func"} sless-operator (trigger_controller.go): reconcileEvent → валидирует что Function существует → устанавливает status.active = true event-dispatcher (services/event-dispatcher/): k8s informer наблюдает Trigger CRD по всем namespace При type=event → amqp.Channel.Consume(spec.queue) При сообщении → POST http://..svc.cluster.local:8080/ → 2xx → ack → не 2xx / timeout → nack (requeue) При удалении Trigger → закрыть consumer ``` ### Что меняется в коде | Файл | Изменение | |------|-----------| | `api/v1alpha1/trigger_types.go` | +TriggerTypeEvent, +Queue в TriggerSpec | | `controllers/trigger_controller.go` | +reconcileEvent (валидация + status) | | `internal/config/config.go` | +RabbitMQURL | | `services/event-dispatcher/` | новый Go-сервис (main + dispatcher + watcher) | | `deployments/k8s/event-dispatcher.yaml` | Deployment + ServiceAccount + ClusterRole | ### Инфраструктура RabbitMQ: managed через Nubes (Вариант A требует стабильного брокера). - Управляется rabbitmq-operator в namespace `operators` - namespace: `1dbfe9da-ce1c-4958-b359-d016a4b455c8` - host: `rabbitmqk8s.1dbfe9da-ce1c-4958-b359-d016a4b455c8.svc.cluster.local` - credentials: в `sless-operator-secret` (RABBITMQ_URL) — добавить при деплое ## 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, оператор v0.1.34 + funcs-service v0.2.0) **Принцип:** минимум изменений в операторе, максимум логики в `sless-funcs-service`. **Два новых эндпоинта в операторе:** | Метод | Путь | Что делает | |-------|------|-----------| | GET | `/v1/namespaces/{ns}/functions/{name}/source` | Читает tar.gz из S3 (`Function.Spec.S3Key`) → JSON `[{name, content}]`, без Dockerfile | | PATCH | `/v1/namespaces/{ns}/triggers/{name}` | `{"enabled": bool}` → обновляет Trigger CRD | **`sless-funcs-service` — HTML режим:** - Если запрос из браузера (`Accept: text/html`) → отдаёт HTML страницу - Список функций — аккордеон; при раскрытии `fetch(/funcs/{ns}/source/{fn})` подгружает файлы - Подсветка синтаксиса: `highlight.js` с CDN (не требует сборки) - Кнопки ▶ Старт / ■ Стоп → `PATCH /funcs/{ns}/triggers/{name}` через fetch - HTML шаблон `index.html` встроен в бинарник через `//go:embed index.html` - `text/plain` ответ для curl/CLI остаётся без изменений (браузер шлёт `Accept: text/html`, curl — нет) --- ## 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/.ssh/naeel_vm_id_ed25519" \ /home/naeel/remote_dev/sless/examples// \ naeel@5.172.178.213:/home/naeel/terra/sless/examples// # Затем запускать на remote: ssh -i /home/naeel/.ssh/naeel_vm_id_ed25519 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). --- ## 2026-03-19 — pgx/v5 как PG-драйвер для Go функций (vs database/sql + lib/pq) **Контекст:** Go runtime v0.1.1 — добавляем прямой доступ к PostgreSQL из функций. Нужно выбрать: database/sql + lib/pq, или чистый pgx/v5? **Решение:** Использовать `github.com/jackc/pgx/v5` напрямую, без обёртки database/sql. **Причины:** 1. **pgxpool из коробки** — `pgxpool.New()` без дополнительных пакетов. lib/pq требует `sql.Open` + настройку пула через `db.SetMaxOpenConns` и т.д. 2. **Нативный протокол PostgreSQL** — pgx реализует wire protocol напрямую, без CGO. lib/pq тоже pure Go, но pgx быстрее (~20% в бенчмарках) и активнее поддерживается. 3. **Контекст-нативность** — `pgxpool.Pool.Query(ctx, ...)` — context как первый аргумент везде. В database/sql контекст пришёл только в Go 1.8 как `QueryContext` — неудобный retrofit. 4. **Сканирование строк** — `pgx.CollectRows`, `pgx.ForEachRow` — удобнее чем `rows.Scan`. 5. **Экосистема** — pgx — де-факто стандарт в Go+PG проектах (используется в pgx, pgvector, ent). **Что добавлено в рантайм:** ``` github.com/jackc/pgx/v5 v5.7.2 github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-... // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect (connection pool) golang.org/x/crypto v0.31.0 // indirect (scram auth) golang.org/x/sync v0.10.0 // indirect golang.org/x/text v0.21.0 // indirect ``` **go mod download** добавлен в Dockerfile до COPY server.go — слой с зависимостями кешируется отдельно. Пересборка функции (только изменение handler.go) не перекачивает ~15MB зависимостей. --- ## 2026-03-19 — Динамический таймаут в invoke.go из Function.Spec.TimeoutSec **Контекст:** invoke.go проксирует HTTP-запросы к подам функций. До этого — глобальный `http.Client{Timeout: 30s}`. **Проблема:** 30s — константа времени написания кода. Функции с `timeout_sec=700` (stress-тесты, batch-задачи) падают с `context deadline exceeded` раньше чем успевают завершиться. **Решение:** Перед каждым вызовом читать `Function.Spec.TimeoutSec` из k8s и создавать `http.Client` с таймаутом = `TimeoutSec + 5s`. **Почему +5s буфер:** - Нельзя ставить ровно `TimeoutSec` — есть сетевые задержки, TLS handshake, время на DNS резолв внутри кластера. - 5s достаточно для любых сетевых задержек в локальном k8s кластере. - Если функция реально завис на TimeoutSec — runtime сам должен прервать работу (это ответственность функции, не прокси). **Почему не кешировать http.Client:** - Каждый вызов может прийти к разной функции с разным TimeoutSec. - http.Client создаётся дёшево — только структура с одним полем Timeout. - Кеш потребовал бы sync.Map или mutex — лишняя сложность без измеримой пользы. **Деградация при недоступности k8s:** ```go if err := h.K8s.Get(r.Context(), client.ObjectKey{...}, fn); err == nil { timeoutSec = fn.Spec.TimeoutSec } // если Get упал — timeoutSec=0 → invokeHTTPClient вернёт 30s (дефолт) ``` Это осознанный выбор: если мы не можем прочитать функцию — мы не знаем её таймаут, используем разумный дефолт вместо возврата ошибки. **Коммит:** `d7fda15`, оператор `v0.1.40` --- ## 2026-03-22 — Стратегия тестирования: G13 / G14 / G15 ### Решение: разделить тесты на три группы **Контекст:** После G12 failure test (45/45) нужно было покрыть оставшиеся сценарии. **Варианты:** 1. Один большой тест-файл со всеми сценариями 2. Три отдельных группы по типу **Выбрано:** Три отдельных файла: - `operator_edge_cases_test.sh` (G13) — пользовательские ошибки и граничные случаи - `operator_chaos_test.sh` (G14) — кластерный хаос (удаление ресурсов, kill pods) - `operator_combined_test.sh` (G15) — комбинированный: хаос + пользовательские ошибки одновременно **Почему:** Разные группы можно запускать независимо; G14 требует прав `kubectl` на деструктивные операции — отдельный файл делает намерение явным; время прогона ~25-50 мин каждый. --- ### Решение: `GET /fn/` разрешает все HTTP методы **Контекст:** Тест G13D-3 ожидал 405 при GET на invoke-endpoint. **Факт:** `router.go` строка 25: `r.PathPrefix("/fn/{namespace}/{name}").HandlerFunc(h.InvokeFunction)` — PathPrefix без `.Methods()` принимает ВСЕ методы. **Решение:** Это архитектурный выбор: runtime-функция сама решает что делать с методом. Endpoint `/fn/` — это прокси, не контроллируемый API. **Задокументировано:** В тесте G13D-3 как by-design поведение. --- ### Решение: G15D тест принимает 503 как transient **Контекст:** При `kubectl delete pod` operator pod — API server временно недоступен. **Факт:** Operator pod содержит и API-server и controller в одном бинарнике. Время перезапуска pod ~5-15s, в это время nginx/ingress отдаёт 503. **Решение:** Тест документирует это как ожидаемое поведение (NOTE), передаёт как PASS. Если нужна HA — требуется multi-replica оператор (отдельное решение). **Gap:** Для production нужен отдельный API-deployment с ≥2 replicas. --- ## 2026-04-06 — IoT bridge: Kafka write должен быть async (v0.1.69) ### Контекст Load test (100 msg burst) показал потерю 73/100 сообщений. Первоначально записал в "backlog". Пользователь указал: это не backlog — это архитектурная ошибка. Между компонентами pipeline не должно быть синхронных зависимостей. ### Решение `kafka.Writer{Async: true}` — единственно правильный вариант для MQTT callback. ### Варианты которые рассматривались 1. **`Async: true` в kafka.Writer** — выбрано. Минимальное изменение, kafka-go сам управляет буфером и горутиной записи. 2. **Channel + отдельная горутина в handler** — избыточно. Дублирует то, что kafka-go уже делает внутри при Async=true. Лишний слой. 3. **Увеличить keepalive timeout** — не решает проблему, только отодвигает симптом. ### Почему `Async: true` безопасно - Ошибки доставки идут в `ErrorLogger` — логируются, не теряются бесследно - При shutdown: `kafkaWriter.Close()` (defer) дожидается flush буфера перед выходом - При недоступности Kafka: kafka-go внутри делает retry, сообщения в памяти-буфере ### Принцип на будущее **Каждое звено pipeline должно принимать и отдавать сообщения немедленно.** Любой blocking call внутри event handler — потенциальная точка потери данных.