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 контроллеров, примеры кода, ТЗ, вопросы
This commit is contained in:
“Naeel”
2026-03-11 08:47:50 +04:00
parent a1774e178f
commit bca889d355
4 changed files with 940 additions and 39 deletions
@@ -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` — рабочий пример использования
+138 -39
View File
@@ -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 <token>`.
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 <nubes_endpoint>) с тем же токеном
- 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 |
+121
View File
@@ -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 <nubes_endpoint>` с 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`.
+38
View File
@@ -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 | Низкий |