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