diff --git a/doc/architecture/agent-handoff-2026-03-11.md b/doc/architecture/agent-handoff-2026-03-11.md new file mode 100644 index 0000000..3a73939 --- /dev/null +++ b/doc/architecture/agent-handoff-2026-03-11.md @@ -0,0 +1,643 @@ +# 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` — рабочий пример использования diff --git a/doc/architecture/overview.md b/doc/architecture/overview.md index b6250a0..147b5df 100644 --- a/doc/architecture/overview.md +++ b/doc/architecture/overview.md @@ -1,64 +1,163 @@ # Архитектура системы +Последнее обновление: 2026-03-11 + ## Общее описание Managed Serverless Functions Service для облачного провайдера nubes.ru. -Пользователь загружает код, сервис его собирает и запускает по HTTP-триггеру или расписанию. +Пользователь загружает код через Terraform, сервис его собирает (kaniko) и запускает +по HTTP-триггеру, расписанию (cron) или вручную через one-shot Job. ## Стек | Компонент | Технология | Где запущен | |-----------|-----------|-------------| -| API сервер | Go | Kubernetes, namespace `sless` | -| PostgreSQL | PostgreSQL | Kubernetes, namespace `sless` | -| Redis | Redis | Kubernetes, namespace `sless` | -| RabbitMQ | RabbitMQ | Kubernetes, namespace `sless` (позже) | -| S3 | Ceph (облачный) | `ceph.tst.nubes.ru` | -| Container Registry | Внутренний registry кластера | namespace `registry` | -| Функции пользователей | k8s Jobs/Deployments | namespace `sless-fn-{id}` | +| Operator (API + Controllers) | Go (controller-runtime) | Kubernetes, namespace `sless` | +| PostgreSQL | PostgreSQL 16 | Kubernetes, namespace `sless` | +| S3 | Ceph (облачный) | `s3.msk-1.ngcloud.ru` | +| Container Registry | DockerHub (`naeel/`) | внешний | +| Builder | kaniko (k8s Job) | namespace пользователя | +| Функции (HTTP) | k8s Deployment + Service | namespace пользователя | +| Функции (one-shot) | k8s Job | namespace пользователя | +| Функции (cron) | k8s CronJob | namespace пользователя | +| Terraform Provider | Go (plugin framework v6) | localhost/CI | +| nubes API | REST (облако) | `deck-api.ngcloud.ru` | -## Схема +> Redis и RabbitMQ — отложены до v2. + +## Изоляция пользователей — Namespace per user + +Каждый пользователь облака получает **отдельный k8s namespace**. ``` -Пользователь - │ - ▼ -REST API (Go) ←── Terraform provider - │ - ├── PostgreSQL — метаданные функций, версии, логи вызовов - ├── S3 (Ceph) — хранение кода (zip архивы) - ├── Redis — кеш, rate limiting - │ - ▼ -Builder — получает zip из S3, собирает Docker образ, пушит в registry - │ - ▼ -Runner (k8s) — деплоит функцию как Job/Deployment в k8s - │ - ▼ -RabbitMQ — async вызовы, cron triggers (v2) +JWT токен (Bearer) + └─► JWT.sub (строка "0199e325-1cdf-7cda-9319-e5302a85e291") + └─► SHA256(sub) → первые 8 байт → hex → "sless-{16 hex символов}" + └─► namespace = "sless-cdd874dfa31ba6ca" ``` +- Namespace детерминирован: один sub → всегда один namespace. +- sub не раскрывается в имени namespace (SHA256 необратим). +- Длина 22 символа — укладывается в лимит k8s (63). + +**Кто создаёт namespace:** +Terraform провайдер при Configure() вызывает POST /v1/namespaces/{ns}/ensure +**один раз**, до любых ресурсных операций. +Resource-хендлеры (Function, Trigger, Job) namespace **не создают** — это не их ответственность. + ## Аутентификация -Используется токен облака (Bearer token), который пользователь получает в UI облака. -Terraform provider передаёт его в заголовке `Authorization: Bearer `. -Keycloak не используется. +### Оператор (REST API) +- Bearer JWT в заголовке Authorization +- Проверяется структура JWT (3 части), наличие sub claim, срок действия exp +- Подпись **не проверяется** — trusted perimeter (оператор за Ingress) -## Мониторинг +### Terraform Provider (при Configure) +1. Декодирует JWT → sub +2. Вычисляет namespace через SHA256 +3. Если задан nubes_endpoint — пингует nubes API (GET ) с тем же токеном + - HTTP 401/403 → ошибка инициализации провайдера + - Недоступен → ошибка инициализации +4. Вызывает POST /v1/namespaces/{ns}/ensure (создаёт namespace если нет) -Метрики функций → Victoria Metrics / Grafana (уже есть в облаке). -Grafana: https://grafana.ngcloud.ru/dashboards/... +## Схема вызова + +``` +Пользователь (curl / браузер) + | + v GET|POST|... /fn/{namespace}/{name}/* +sless-api Ingress -> Operator /fn/ прокси + | + v HTTP forward -> http://{name}.{namespace}.svc.cluster.local:8080 +k8s Service -> Deployment/Pod функции +``` + +``` +terraform apply + | + v provider Configure() + 1. JWT -> sub -> namespace + 2. PingNubesAPI (валидация токена) + 3. POST /v1/namespaces/{ns}/ensure <- создаёт k8s namespace + | + +-> POST /v1/namespaces/{ns}/functions <- создаёт Function CRD + | +-> POST /upload (zip) <- загружает код -> S3 -> kaniko Job + | +-> polling phase=Ready + | + +-> POST /v1/namespaces/{ns}/triggers <- создаёт Trigger CRD + | +-> controller: Deployment + Service + (CronJob для cron) + | + +-> POST /v1/namespaces/{ns}/jobs <- создаёт FunctionJob CRD + +-> controller: k8s Job -> result в status +``` + +## Структура кода + +``` +sless/ +|-- main.go точка входа: k8s manager + REST API сервер (goroutine) +|-- internal/ +| |-- api/ +| | |-- router.go gorilla/mux: /fn/ (публичный), /v1/ (auth + middleware) +| | |-- handler/ +| | | |-- handler.go Handler struct + helpers (writeJSON, namespace(), pathVar()) +| | | |-- namespace.go EnsureNamespace (POST /v1/namespaces/{ns}/ensure) +| | | |-- functions.go CRUD Function +| | | |-- triggers.go CRUD Trigger +| | | |-- jobs.go CRUD FunctionJob +| | | |-- upload.go zip -> Dockerfile -> tar.gz -> S3 -> CRD patch +| | | |-- invoke.go прокси /fn/{ns}/{name} -> in-cluster DNS +| | | +-- invocations.go 501 stub (реализация отложена) +| | +-- middleware/ +| | |-- auth.go JWT validation (struct + sub + exp, подпись не проверяется) +| | +-- logging.go slog request logger +| |-- builder/builder.go kaniko Job lifecycle (Build, JobStatus, Cleanup) +| |-- config/config.go Load() из env vars +| +-- storage/ +| |-- postgres/store.go SaveInvocation, ListInvocations, RunMigrations +| +-- s3/client.go Upload, Download, UploadContext (tar.gz для kaniko) +|-- controllers/ +| |-- function_controller.go Reconcile: Pending->Building->Ready/Failed + Deployment +| |-- trigger_controller.go Reconcile: Service+Ingress (http) / CronJob (cron) +| +-- functionjob_controller.go Reconcile: k8s Job -> Succeeded/Failed + output capture +|-- api/v1alpha1/ +| |-- function_types.go Function CRD +| |-- trigger_types.go Trigger CRD +| +-- job_types.go FunctionJob CRD +|-- deployments/k8s/ +| |-- operator.yaml Deployment + Service + Ingress +| +-- rbac.yaml ClusterRole + ClusterRoleBinding + ServiceAccount +|-- terraform/provider/ независимый Go-модуль +| +-- internal/ +| |-- client/client.go SubFromJWT, NamespaceFromSub, PingNubesAPI + CRUD +| |-- provider/provider.go Configure(): JWT->NS->ping->EnsureNamespace +| +-- resources/ +| |-- function_resource.go sless_function +| |-- trigger_resource.go sless_trigger +| +-- job_resource.go sless_job ++-- runtimes/ + |-- python3.11/ server.py + Dockerfile -> naeel/sless-runtime-python3.11:v0.1.1 + +-- nodejs20/ server.js + Dockerfile -> naeel/sless-runtime-nodejs20:v0.1.2 +``` ## Kubernetes кластер -Сейчас используется существующий кластер (временно). +Сейчас используется существующий кластер (временный). Планируется переезд на новый кластер — манифесты переносятся без изменений. -Ноды существующего кластера: -- `wheel-control-plane-fm9sr` — control-plane -- `wheel-workers-tv4qr-r45xs` — worker -- `wheel-workers-tv4qr-x8xw7` — worker +Ноды: +- wheel-control-plane-fm9sr — control-plane +- wheel-workers-tv4qr-r45xs — worker +- wheel-workers-tv4qr-x8xw7 — worker -Ingress: nginx, external IP `5.172.178.182` -Storage: rawfile CSI (local-path, default) +Ingress: nginx, external IP 5.172.178.182 +API endpoint: https://sless-api.kube5s.ru + +## Версии в production + +| Артефакт | Тег/Версия | +|---------|-----------| +| naeel/sless-operator | v0.1.21 | +| terra.k8c.ru/naeel/sless провайдер | v0.1.13 | +| naeel/sless-runtime-python3.11 | v0.1.1 | +| naeel/sless-runtime-nodejs20 | v0.1.2 | diff --git a/doc/decisions/log.md b/doc/decisions/log.md index 5e295a8..ba27fcf 100644 --- a/doc/decisions/log.md +++ b/doc/decisions/log.md @@ -399,3 +399,124 @@ if h.Validator != nil { - 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`. diff --git a/doc/progress.md b/doc/progress.md index e55d46b..f2f74bc 100644 --- a/doc/progress.md +++ b/doc/progress.md @@ -129,3 +129,41 @@ | 5 | Pre-warm для cron триггеров | ⏳ | TriggerSpec.PreWarmSeconds — поле есть, логика не реализована | | 11 | trigger.enabled | ✅ | enabled=false → Deployment replicas=0, in-place update через PATCH | | 12 | job.run_id | ✅ | run_id=0 → skip, run_id>0 → execute. Повторный запуск = увеличить run_id | + +--- + +## 2026-03-11 — Namespace-per-user + SoC рефакторинг + +| # | Задача | Статус | Заметки | +|---|--------|--------|---------| +| 1 | JWT decode в провайдере (SubFromJWT, NamespaceFromSub) | ✅ | `terraform/provider/internal/client/client.go` | +| 2 | PingNubesAPI в provider.Configure() | ✅ | атрибут nubes_endpoint, env NUBES_ENDPOINT | +| 3 | JWT auth в операторе (validateJWT vs статический токен) | ✅ | `internal/api/middleware/auth.go` — sub + exp, без подписи | +| 4 | EnsureNamespace как отдельный endpoint | ✅ | `POST /v1/namespaces/{ns}/ensure`, `handler/namespace.go` | +| 5 | router.go: маршрут `/ensure` | ✅ | добавлен перед Functions CRUD | +| 6 | client.go провайдера: метод EnsureNamespace | ✅ | `terraform/provider/internal/client/client.go` | +| 7 | provider.Configure(): вызов EnsureNamespace | ✅ | namespace создаётся один раз при init | +| 8 | handler.go: убраны k8s-типы (SoC) | ✅ | только Handler struct + helpers | +| 9 | secrets/ исключены из git (.gitignore) | ✅ | токены не попадают в репу | +| 10 | E2E тест: apply + destroy | ✅ | namespace sless-cdd874dfa31ba6ca, все 4 ресурса | +| 11 | operator v0.1.21 задеплоен | ✅ | naeel/sless-operator:v0.1.21 | +| 12 | provider v0.1.13 опубликован | ✅ | terra.k8c.ru/naeel/sless v0.1.13 | +| 13 | commit + push feat/namespace-per-user | ✅ | a1774e1 | + +**Текущая ветка:** feat/namespace-per-user +**Последний коммит:** a1774e1 + +--- + +## Остаток технического долга (не блокирует) + +| # | Что | Приоритет | +|---|-----|-----------| +| 1 | `upload.go`: generateDockerfile/zipToTarGz — builder logic в HTTP handler | Средний | +| 2 | `ensureRegistrySecret` в FunctionReconciler — cross-namespace инфра операция | Низкий | +| 3 | `invocations.go` — 501 stub, PG подключён но endpoint не реализован | Средний | +| 4 | LLM-валидация кода при upload | Средний (решение задизайнено в decisions/log.md) | +| 5 | Scale-to-zero через KEDA HTTP Add-on | Низкий (v2) | +| 6 | replicas field в FunctionSpec | Низкий (v1.1) | +| 7 | RabbitMQ event triggers | Низкий (v2) | +| 8 | Метрики → Victoria Metrics | Низкий |