- 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 контроллеров, примеры кода, ТЗ, вопросы
26 KiB
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):
- Читаем endpoint + token
- SubFromJWT(token) -> sub
- NamespaceFromSub(sub) -> namespace = "sless-{sha256(sub)[:8]hex}"
- PingNubesAPI(nubes_endpoint, token) -> 401/403 = ошибка
- client.New(endpoint, token, namespace)
- 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. Технический долг
Средний приоритет (реально нужно)
-
upload.go: builder logic в HTTP handlergenerateDockerfile(),runtimeBaseImage(),zipToTarGz()— это логика сборщика- Должно быть в
internal/builder/или отдельном builderconfig пакете - HTTP handler должен только принять zip и вызвать builder.PrepareContext()
-
invocations.go: 501 stub- PostgreSQL подключён, RunMigrations работает, таблица
invocationsсоздана - Логика ListInvocations в postgres/store.go уже есть
- Осталось только подключить endpoint к store
- PostgreSQL подключён, RunMigrations работает, таблица
-
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)
-
ensureRegistrySecretв FunctionReconciler- Копирует DockerHub secret в namespace пользователя
- Cross-namespace инфраструктурная операция в бизнес-контроллере
- Идеально — отдельный контроллер или admission webhook
-
replicas field в FunctionSpec
- Позволит пользователю
replicas=0(выключить без удаления) - Пока enabled/disabled только через Trigger.Enabled
- Позволит пользователю
-
Scale-to-zero (KEDA)
- Заменить Deployment на HTTPScaledObject (KEDA HTTP Add-on)
- minReplicas=0, cold start ~1-3 сек
- Требует установки KEDA в кластер
-
Инвокации history (v2)
- Логирование каждого вызова в PostgreSQL
- invoke.go -> SaveInvocation -> ListInvocations endpoint
-
RabbitMQ event triggers (v2)
- Подписка на очередь -> вызов функции
- EventDispatcher компонент
-
Приватный Docker registry
- Сейчас DockerHub — образы функций публичны
- Для production: Harbor / ECR / GCR
-
Метрики в 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
Следующему агенту предлагается ответить на:
-
Builder SoC: Правильно ли выносить
generateDockerfile/zipToTarGzвinternal/builder/? Как это соотносится с тем что контроллер уже использует builder.Build()? Какой интерфейс был бы оптимальным? -
LLM-валидация: Дизайн в decisions/log.md — что в нём не учтено? Как обрабатывать false positives (пользователи которые получат 400 незаслуженно)? Нужен ли ручной override/whitelist?
-
Namespace lifecycle: Сейчас namespace не удаляется при
terraform destroy. Это намеренно (данные не теряются при случайном destroy)? Или нужен endpoint DELETE /v1/namespaces/{ns} с принудительной очисткой? -
Security: JWT подпись: Стоит ли добавить проверку подписи через JWKS URI nubes? Это усложнит архитектуру, но даст дополнительный слой защиты. При каком масштабе/угрозах это становится необходимым?
-
ensureRegistrySecret: Сейчас копирование DockerHub secret в namespace пользователя делается в FunctionReconciler. Это admission webhook? Отдельный reconciler? Какой паттерн правильнее для cross-namespace секретов в k8s?
-
Единый бинарник: При каком масштабе нагрузки оправдано разделение 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. Файлы для детального чтения
Для понимания кода рекомендуется читать в порядке:
api/v1alpha1/function_types.go— что такое Functioninternal/api/handler/handler.go+namespace.go— базовая инфраструктураinternal/api/handler/functions.go— CRUD примерinternal/api/handler/upload.go— загрузка кода (TODO: перенести builder logic)internal/api/router.go— все маршрутыinternal/api/middleware/auth.go— JWT validationcontrollers/function_controller.go— основной reconcile loopinternal/builder/builder.go— kaniko Job managementterraform/provider/internal/provider/provider.go— Configure()terraform/provider/internal/client/client.go— HTTP-клиентterraform/provider/internal/resources/function_resource.go— terraform ресурсexamples/hello-node/main.tf— рабочий пример использования