# Решения и обоснования ## 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/.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).