# Agent Handoff — 2026-03-11 Документ для передачи контекста следующему агенту (Claude Opus). Охватывает всё что реализовано, ключевые решения, текущее состояние кода, технический долг и вопросы для анализа. --- ## 1. Что такое этот проект Managed Serverless Functions Service — платформа для запуска пользовательских функций в облаке nubes.ru. **Аналог:** AWS Lambda, Yandex Cloud Functions, но для собственного облака. **Цель:** пользователь пишет функцию (Python/Node.js), загружает через Terraform, получает HTTP endpoint или триггер по расписанию. Вся инфраструктура скрыта. **Текущий статус:** MVP работает в production кластере. Идёт итеративное улучшение. --- ## 2. Стек и инфраструктура ``` Пользователь -> Terraform provider sless (terra.k8c.ru/naeel/sless v0.1.13) -> REST API оператора (https://sless-api.kube5s.ru) -> Kubernetes кластер (существующий, namespace sless) -> S3 (Ceph, s3.msk-1.ngcloud.ru) — хранение кода -> DockerHub (naeel/) — хранение образов функций -> kaniko (k8s Job) — сборка Docker образов из кода -> Deployments/Jobs/CronJobs — запуск функций ``` **Kubernetes кластер:** - 1 control-plane + 2 workers - Ingress nginx, external IP 5.172.178.182 - StorageClass local-path (rawfile CSI / OpenEBS) - cert-manager, Kyverno, Cilium CNI - kubectl: KUBECONFIG=~/.kube/wheel.conf **Namespace оператора:** sless **Operator image:** naeel/sless-operator:v0.1.21 **Provider version:** terra.k8c.ru/naeel/sless v0.1.13 --- ## 3. Архитектура — компоненты ### Оператор (один Go бинарник) ``` main.go | +-- k8s manager (controller-runtime) | +-- FunctionReconciler (функции lifecycle) | +-- TriggerReconciler (HTTP/cron триггеры) | +-- FunctionJobReconciler (one-shot запуски) | +-- REST API сервер (goroutine, :9090) +-- /fn/{ns}/{name} — публичный прокси вызова функций (без auth) +-- /v1/... — управление ресурсами (JWT auth) ``` #### REST API маршруты ``` POST /v1/namespaces/{ns}/ensure <- EnsureNamespace GET /v1/namespaces/{ns}/functions <- ListFunctions POST /v1/namespaces/{ns}/functions <- CreateFunction GET /v1/namespaces/{ns}/functions/{name} <- GetFunction PUT /v1/namespaces/{ns}/functions/{name} <- UpdateFunction DELETE /v1/namespaces/{ns}/functions/{name} <- DeleteFunction POST /v1/namespaces/{ns}/functions/{name}/upload <- UploadCode (zip) GET /v1/namespaces/{ns}/functions/{name}/invocations <- 501 (не реализован) GET /v1/namespaces/{ns}/triggers <- ListTriggers POST /v1/namespaces/{ns}/triggers <- CreateTrigger GET /v1/namespaces/{ns}/triggers/{name} <- GetTrigger PATCH /v1/namespaces/{ns}/triggers/{name} <- UpdateTrigger (enabled) DELETE /v1/namespaces/{ns}/triggers/{name} <- DeleteTrigger POST /v1/namespaces/{ns}/jobs <- CreateJob GET /v1/namespaces/{ns}/jobs/{name} <- GetJob DELETE /v1/namespaces/{ns}/jobs/{name} <- DeleteJob ANY /fn/{namespace}/{name}/* <- InvokeFunction (прокси) ``` #### CRD ресурсы **Function:** ```go FunctionSpec { Runtime string // "python3.11" | "nodejs20" Entrypoint string // "handler.handle" (python) / игнорируется (node) S3Key string // contexts/{ns}/{name}/{ts}.tar.gz S3Bucket string MemoryMB int32 TimeoutSec int32 Env []corev1.EnvVar } FunctionStatus { Phase FunctionPhase // Pending | Building | Ready | Failed ImageRef string // naeel/sless-{ns}-{name}:{sha12} Message string LastBuiltAt metav1.Time } ``` **Trigger:** ```go TriggerSpec { Type TriggerType // http | cron FunctionRef string Schedule string // cron expression Enabled bool // false -> replicas=0 (функция не принимает запросы) } TriggerStatus { Active bool URL string // https://sless-api.kube5s.ru/fn/{ns}/{name} LastScheduleTime *metav1.Time } ``` **FunctionJob:** ```go FunctionJobSpec { FunctionRef string EventJSON string // произвольный JSON-payload для функции RunID int64 // 0=skip, >0=run; увеличить для повторного запуска } FunctionJobStatus { Phase FunctionJobPhase // Pending | Skipped | Running | Succeeded | Failed JobName string StartTime *metav1.Time CompletionTime *metav1.Time Message string // stdout функции (результат) } ``` ### Terraform Provider **Ресурсы:** - `sless_function` — управление функцией (CRUD + upload + WaitReady) - `sless_trigger` — управление триггером (CRUD + WaitGone при Delete) - `sless_job` — one-shot запуск (Create + WaitJobDone если run_id>0) **Provider конфигурация:** ```hcl provider "sless" { endpoint = "https://sless-api.kube5s.ru" token = file("./secrets/prod.token") nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1" # env alternatives: SLESS_ENDPOINT, SLESS_API_TOKEN, NUBES_ENDPOINT } ``` **Инициализация (Configure):** 1. Читаем endpoint + token 2. SubFromJWT(token) -> sub 3. NamespaceFromSub(sub) -> namespace = "sless-{sha256(sub)[:8]hex}" 4. PingNubesAPI(nubes_endpoint, token) -> 401/403 = ошибка 5. client.New(endpoint, token, namespace) 6. c.EnsureNamespace(ctx, namespace) -> POST /v1/namespaces/{ns}/ensure --- ## 4. Ключевые архитектурные решения ### Namespace per user Изоляция пользователей через k8s namespace: ``` namespace = "sless-" + hex(SHA256(JWT.sub)[:8]) ``` - Детерминирован (один sub = один namespace всегда) - Необратим (нельзя восстановить sub из namespace) - Длина 22 символа (< лимита k8s 63) - Пример реального namespace: sless-cdd874dfa31ba6ca ### Разделение ответственностей (SoC) handler/ package намеренно разделён по файлам: ``` handler.go — только инфраструктура (Handler struct, helpers) namespace.go — EnsureNamespace (k8s namespace lifecycle) functions.go — CRUD Function triggers.go — CRUD Trigger jobs.go — CRUD FunctionJob upload.go — код -> S3 -> kaniko invoke.go — прокси /fn/ ``` **Правило:** resource-хендлеры не создают namespace. Namespace создаётся один раз в namespace.go через отдельный endpoint. ### JWT auth в операторе Проверяется: структура JWT (3 части) + sub claim существует + exp не истёк. Подпись НЕ проверяется — trusted perimeter. ### Аутентификация токена через nubes API Токен считается валидным если nubes API не вернул 401/403. Это происходит один раз при terraform init/apply в Configure(). ### Два провайдера — нельзя объединять `sless` и `nubes` — два отдельных Terraform провайдера. Разные зоны ответственности, разные релизные циклы. ### DockerHub вместо registry в кластере namespace `registry` — это Apache NiFi Registry (не Docker!). Образы функций: `naeel/sless-{ns}-{name}:{sha12}` на DockerHub. Компромисс: образы публичны. Для production нужен приватный registry. ### HTTP прокси /fn/ вместо wildcard DNS У облачного провайдера нет возможности создать wildcard DNS *.fn.kube5s.ru. Вместо этого оператор сам проксирует запросы: ``` GET https://sless-api.kube5s.ru/fn/{namespace}/{name}/path?query -> GET http://{name}.{namespace}.svc.cluster.local:8080/path?query ``` ### Kaniko сборка образов kaniko запускается как k8s Job в namespace пользователя. Контекст сборки — tar.gz в S3 (zip от пользователя перепаковывается). Dockerfile генерируется автоматически из runtime (пользователь не видит). ``` POST /upload (zip) -> распаковка zip -> (TODO: LLM-валидация кода) -> generateDockerfile(runtime) -> zipToTarGz -> S3 -> Function.Spec.S3Key = новый ключ -> контроллер видит изменение -> запускает kaniko Job ``` ### WaitReady после upload terraform apply блокируется до phase=Ready (kaniko сборка ~1 мин). Polling каждые 5 сек, таймаут default 300 сек. Без этого terraform state показывал бы phase=Building. ### code_hash для детектирования изменений Атрибут `code_hash` в sless_function. Пользователь задаёт через `filesha256("./handler.js")`. Изменение hash -> провайдер перезагружает zip -> пересборка. НЕ использовать output_md5 от hashicorp/archive — там баг (MD5 не обновляется). --- ## 5. Структура файлов провайдера ``` terraform/provider/ go.mod module: terraform-provider-sless main.go запуск провайдера через plugin framework internal/ client/client.go HTTP-клиент к REST API оператора SubFromJWT(token) string JWT payload decode -> sub NamespaceFromSub(sub) string SHA256[:8] -> "sless-{hex16}" PingNubesAPI(ctx, ep, token) GET запрос к nubes API New(endpoint, token, ns) создаёт Client EnsureNamespace(ctx, ns) POST /v1/namespaces/{ns}/ensure CreateFunction/GetFunction/UpdateFunction/DeleteFunction UploadCode/UploadCodeReader CreateTrigger/GetTrigger/UpdateTrigger/DeleteTrigger CreateJob/GetJob/DeleteJob WaitReady(ctx, ns, name, timeout) WaitJobDone(ctx, ns, name, timeout) provider/provider.go Configure() - JWT->NS->ping->EnsureNamespace, создаёт client resources/ function_resource.go source_dir атрибут zipDir() в памяти, sha256 автоматически code_path атрибут путь к готовому zip code_hash атрибут filesha256(source_file) для детектирования build_timeout_sec таймаут ожидания kaniko (default 300) trigger_resource.go enabled атрибут false -> replicas=0 in-place PATCH job_resource.go run_id атрибут 0=skip, >0=run, повторный запуск = увеличить wait_timeout_sec таймаут ожидания job ``` --- ## 6. Lifecycle контроллеров ### FunctionReconciler ``` Function CRD создан -> phase=Pending S3Key задан? Нет -> ждём upload Да -> Уже Building? Нет -> запустить kaniko Job (startBuild) Да -> проверить статус Job (checkBuild) succeeded? -> обновить ImageRef, S3Key аннотацию, phase=Ready failed? -> phase=Failed phase=Ready? -> ensureDeployment (создать/обновить Deployment) + ensureRegistrySecret (скопировать DockerHub secret в NS пользователя) Удаление (finalizer)? -> удалить Deployment + Service + kaniko Jobs ``` **Idempotency guard:** аннотация `last-built-s3key` предотвращает повторный запуск kaniko для одного и того же S3 ключа. **Rollout restart:** при обновлении Deployment проставляется аннотация `kubectl.kubernetes.io/restartedAt = fn.Status.LastBuiltAt` — гарантирует пулл свежего образа даже при :latest теге. ### TriggerReconciler ``` Trigger CRD создан type=http? -> reconcileHTTP: Service + (Ingress если нет ExternalURL) Status.URL = ExternalURL/fn/{ns}/{name} (или Ingress URL) enabled=false? -> patch Deployment replicas=0 enabled=true? -> patch Deployment replicas=1 type=cron? -> reconcileCron: CronJob (вызывает функцию через HTTP по расписанию) Удаление (finalizer)? -> handleTriggerDeletion: удалить Service + Ingress из namespace пользователя ``` ### FunctionJobReconciler ``` FunctionJob CRD создан RunID == 0? -> phase=Skipped, return RunID > 0? Job не создан? -> создать k8s Job Job существует? -> syncJobStatus: проверить Conditions Job Succeeded? -> getJobPodOutput() -> Message = stdout, phase=Succeeded Failed? -> phase=Failed иначе -> RequeueAfter 5s (polling) Удаление? -> удалить k8s Job ``` **Cross-namespace проблема:** FunctionJob в namespace пользователя, k8s Job тоже там. Owns watch убран (не работает cross-namespace). Используется polling (RequeueAfter 5s). --- ## 7. Рантаймы функций ### python3.11 ``` runtimes/python3.11/ server.py Flask-like HTTP сервер :8080 GET /health -> {"status":"ok"} POST /* -> загружает /app/function/{HANDLER_PATH} вызывает handler.handle(event_dict) -> response Dockerfile FROM python:3.11-slim COPY server.py /app/ CMD ["python", "/app/server.py"] Публичный образ: naeel/sless-runtime-python3.11:v0.1.1 ``` **Пользовательский код:** handler.py с `def handle(event): return {...}` ### nodejs20 ``` runtimes/nodejs20/ server.js http.createServer :8080 GET /health -> {"status":"ok"} POST /* -> require(HANDLER_PATH) -> exports.handle(event) Dockerfile FROM node:20-alpine COPY server.js /app/ CMD ["node", "/app/server.js"] Публичный образ: naeel/sless-runtime-nodejs20:v0.1.2 ``` **Пользовательский код:** handler.js с `exports.handle = async (event) => {...}` ### Dockerfile генерируется автоматически При upload оператор генерирует Dockerfile: ``` FROM naeel/sless-runtime-{runtime}:{версия} COPY . /app/function/ RUN pip install -r requirements.txt # если есть (python) RUN npm install --omit=dev # если есть package.json (node) ``` Пользователь никогда не видит Dockerfile. --- ## 8. Пример Terraform конфигурации ```hcl terraform { required_providers { sless = { source = "terra.k8c.ru/naeel/sless" version = "~> 0.1.13" } } } provider "sless" { endpoint = "https://sless-api.kube5s.ru" token = file("${path.module}/../../secrets/prod.token") nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1" } # HTTP функция resource "sless_function" "hello_http" { name = "hello-http" runtime = "nodejs20" source_dir = "${path.module}/code" # папка с handler.js # ИЛИ: # code_path = "${path.module}/handler.zip" # code_hash = filesha256("${path.module}/code/handler.js") memory_mb = 128 timeout_sec = 30 env_vars = { "NODE_ENV" = "production" } } # HTTP триггер resource "sless_trigger" "hello_http" { name = "hello-http-trigger" function_ref = sless_function.hello_http.name type = "http" enabled = true } # Cron триггер resource "sless_trigger" "daily" { name = "daily-job" function_ref = sless_function.hello_http.name type = "cron" schedule = "0 9 * * *" } # One-shot Job resource "sless_job" "hello_run" { name = "hello-run" function_ref = sless_function.hello_http.name run_id = 1 # увеличить для повторного запуска event_json = jsonencode({"input": [100, 200, 300]}) } output "trigger_url" { value = sless_trigger.hello_http.trigger_url } output "job_result" { value = sless_job.hello_run.job_message } ``` --- ## 9. Технический долг ### Средний приоритет (реально нужно) 1. **`upload.go`: builder logic в HTTP handler** - `generateDockerfile()`, `runtimeBaseImage()`, `zipToTarGz()` — это логика сборщика - Должно быть в `internal/builder/` или отдельном builderconfig пакете - HTTP handler должен только принять zip и вызвать builder.PrepareContext() 2. **`invocations.go`: 501 stub** - PostgreSQL подключён, RunMigrations работает, таблица `invocations` создана - Логика ListInvocations в postgres/store.go уже есть - Осталось только подключить endpoint к store 3. **LLM-валидация кода при upload** - Полный дизайн в `doc/decisions/log.md` (раздел 2026-03-10) - Интерфейс `CodeValidator`, `LLMValidator`, `NoopValidator` - Точка вставки: upload.go между распаковкой zip и tar.gz - Soft-fail: LLM недоступен -> предупреждение, деплой продолжается - Blocking: LLM говорит unsafe -> HTTP 400 ### Низкий приоритет (v1.1 / v2) 4. **`ensureRegistrySecret` в FunctionReconciler** - Копирует DockerHub secret в namespace пользователя - Cross-namespace инфраструктурная операция в бизнес-контроллере - Идеально — отдельный контроллер или admission webhook 5. **replicas field в FunctionSpec** - Позволит пользователю `replicas=0` (выключить без удаления) - Пока enabled/disabled только через Trigger.Enabled 6. **Scale-to-zero (KEDA)** - Заменить Deployment на HTTPScaledObject (KEDA HTTP Add-on) - minReplicas=0, cold start ~1-3 сек - Требует установки KEDA в кластер 7. **Инвокации history (v2)** - Логирование каждого вызова в PostgreSQL - invoke.go -> SaveInvocation -> ListInvocations endpoint 8. **RabbitMQ event triggers (v2)** - Подписка на очередь -> вызов функции - EventDispatcher компонент 9. **Приватный Docker registry** - Сейчас DockerHub — образы функций публичны - Для production: Harbor / ECR / GCR 10. **Метрики в Victoria Metrics** - Время сборки, время вызова, ошибки, фазы функций --- ## 10. Известные ограничения | # | Ограничение | Последствие | |---|-------------|-------------| | 1 | DockerHub — публичный registry | Код функций в образах виден всем | | 2 | JWT подпись не проверяется | Внутри trusted perimeter — OK, при публичном доступе — risk | | 3 | Один бинарник (API + Controllers) | Нельзя масштабировать по отдельности | | 4 | Функция без replicas field | Нет ручного выключения без удаления | | 5 | namespace не удаляется при destroy | Пустой namespace остаётся в k8s | | 6 | LLM-валидация не реализована | Код не проверяется перед деплоем | | 7 | invocations endpoint — 501 | История вызовов недоступна | --- ## 11. Вопросы для анализа Opus Следующему агенту предлагается ответить на: 1. **Builder SoC:** Правильно ли выносить `generateDockerfile/zipToTarGz` в `internal/builder/`? Как это соотносится с тем что контроллер уже использует builder.Build()? Какой интерфейс был бы оптимальным? 2. **LLM-валидация:** Дизайн в decisions/log.md — что в нём не учтено? Как обрабатывать false positives (пользователи которые получат 400 незаслуженно)? Нужен ли ручной override/whitelist? 3. **Namespace lifecycle:** Сейчас namespace не удаляется при `terraform destroy`. Это намеренно (данные не теряются при случайном destroy)? Или нужен endpoint DELETE /v1/namespaces/{ns} с принудительной очисткой? 4. **Security: JWT подпись:** Стоит ли добавить проверку подписи через JWKS URI nubes? Это усложнит архитектуру, но даст дополнительный слой защиты. При каком масштабе/угрозах это становится необходимым? 5. **ensureRegistrySecret:** Сейчас копирование DockerHub secret в namespace пользователя делается в FunctionReconciler. Это admission webhook? Отдельный reconciler? Какой паттерн правильнее для cross-namespace секретов в k8s? 6. **Единый бинарник:** При каком масштабе нагрузки оправдано разделение API Server и Controllers на отдельные поды? Какая метрика должна служить триггером для разделения? --- ## 12. Текущее состояние git ``` Branch: feat/namespace-per-user Last commit: a1774e1 Message: "refactor: SoC — EnsureNamespace в namespace.go, маршрут /ensure, client.EnsureNamespace, fix secrets в .gitignore" Файлы в коммите: .gitignore — добавлена secrets/ examples/hello-node/main.tf — версия провайдера ~> 0.1.13 internal/api/handler/functions.go — убран вызов ensureNamespace internal/api/handler/handler.go — убраны k8s-типы, только инфраструктура internal/api/handler/jobs.go — убран вызов ensureNamespace internal/api/handler/namespace.go — НОВЫЙ: EnsureNamespace хендлер internal/api/handler/triggers.go — убран вызов ensureNamespace internal/api/middleware/auth.go — JWT validation (sub+exp) internal/api/router.go — маршрут /ensure добавлен terraform/provider/internal/client/client.go — SubFromJWT, NamespaceFromSub, PingNubesAPI, EnsureNamespace terraform/provider/internal/provider/provider.go — Configure(): JWT->NS->ping->EnsureNamespace ``` Предыдущий коммит в ветке: 5ae2ee7 (JWT auth fix, оператор v0.1.20, провайдер v0.1.12) --- ## 13. Как запустить E2E тест Предварительно: токен в `secrets/prod.token`, KUBECONFIG=~/.kube/wheel.conf ```bash # 1. Проверить кластер KUBECONFIG=~/.kube/wheel.conf kubectl -n sless get pods # 2. Проверить оператор curl -sk https://sless-api.kube5s.ru/fn/nonexistent/ | jq . # 3. Terraform apply cd examples/hello-node terraform init TOKEN=$(cat ../../secrets/prod.token) terraform apply -auto-approve # 4. Вызов функции curl https://sless-api.kube5s.ru/fn/sless-cdd874dfa31ba6ca/hello-http # 5. Cleanup TOKEN=$(cat ../../secrets/prod.token) terraform destroy -auto-approve ``` Ожидаемый результат apply: - Создан namespace sless-cdd874dfa31ba6ca - 2 функции (hello-http nodejs20, hello-job nodejs20) - 1 HTTP триггер - 1 FunctionJob с результатом {"input":[100,200,300],"sum":600} --- ## 14. Файлы для детального чтения Для понимания кода рекомендуется читать в порядке: 1. `api/v1alpha1/function_types.go` — что такое Function 2. `internal/api/handler/handler.go` + `namespace.go` — базовая инфраструктура 3. `internal/api/handler/functions.go` — CRUD пример 4. `internal/api/handler/upload.go` — загрузка кода (TODO: перенести builder logic) 5. `internal/api/router.go` — все маршруты 6. `internal/api/middleware/auth.go` — JWT validation 7. `controllers/function_controller.go` — основной reconcile loop 8. `internal/builder/builder.go` — kaniko Job management 9. `terraform/provider/internal/provider/provider.go` — Configure() 10. `terraform/provider/internal/client/client.go` — HTTP-клиент 11. `terraform/provider/internal/resources/function_resource.go` — terraform ресурс 12. `examples/hello-node/main.tf` — рабочий пример использования