Files
sless/doc/architecture/agent-handoff-2026-03-11.md
T
“Naeel” bca889d355 docs: обновление документации 2026-03-11
- architecture/overview.md — актуальная архитектура: namespace-per-user,
  схема JWT->SHA256->namespace, структура кода, версии в production
- decisions/log.md — новые решения: два провайдера нельзя объединять,
  namespace-per-user, EnsureNamespace как отдельный endpoint (SoC),
  JWT validation вместо статического токена, валидация через nubes API,
  handler.go SoC рефакторинг
- progress.md — статус 2026-03-11 (all done), технический долг
- architecture/agent-handoff-2026-03-11.md — подробный handoff для Opus:
  полная архитектура, lifecycle контроллеров, примеры кода, ТЗ, вопросы
2026-03-11 08:47:50 +04:00

26 KiB
Raw Blame History

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:

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:

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:

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 конфигурация:

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 конфигурации

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)

  1. ensureRegistrySecret в FunctionReconciler

    • Копирует DockerHub secret в namespace пользователя
    • Cross-namespace инфраструктурная операция в бизнес-контроллере
    • Идеально — отдельный контроллер или admission webhook
  2. replicas field в FunctionSpec

    • Позволит пользователю replicas=0 (выключить без удаления)
    • Пока enabled/disabled только через Trigger.Enabled
  3. Scale-to-zero (KEDA)

    • Заменить Deployment на HTTPScaledObject (KEDA HTTP Add-on)
    • minReplicas=0, cold start ~1-3 сек
    • Требует установки KEDA в кластер
  4. Инвокации history (v2)

    • Логирование каждого вызова в PostgreSQL
    • invoke.go -> SaveInvocation -> ListInvocations endpoint
  5. RabbitMQ event triggers (v2)

    • Подписка на очередь -> вызов функции
    • EventDispatcher компонент
  6. Приватный Docker registry

    • Сейчас DockerHub — образы функций публичны
    • Для production: Harbor / ECR / GCR
  7. Метрики в 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

# 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 — рабочий пример использования