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:
@@ -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
@@ -1,64 +1,163 @@
|
|||||||
# Архитектура системы
|
# Архитектура системы
|
||||||
|
|
||||||
|
Последнее обновление: 2026-03-11
|
||||||
|
|
||||||
## Общее описание
|
## Общее описание
|
||||||
|
|
||||||
Managed Serverless Functions Service для облачного провайдера nubes.ru.
|
Managed Serverless Functions Service для облачного провайдера nubes.ru.
|
||||||
Пользователь загружает код, сервис его собирает и запускает по HTTP-триггеру или расписанию.
|
Пользователь загружает код через Terraform, сервис его собирает (kaniko) и запускает
|
||||||
|
по HTTP-триггеру, расписанию (cron) или вручную через one-shot Job.
|
||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
|
|
||||||
| Компонент | Технология | Где запущен |
|
| Компонент | Технология | Где запущен |
|
||||||
|-----------|-----------|-------------|
|
|-----------|-----------|-------------|
|
||||||
| API сервер | Go | Kubernetes, namespace `sless` |
|
| Operator (API + Controllers) | Go (controller-runtime) | Kubernetes, namespace `sless` |
|
||||||
| PostgreSQL | PostgreSQL | Kubernetes, namespace `sless` |
|
| PostgreSQL | PostgreSQL 16 | Kubernetes, namespace `sless` |
|
||||||
| Redis | Redis | Kubernetes, namespace `sless` |
|
| S3 | Ceph (облачный) | `s3.msk-1.ngcloud.ru` |
|
||||||
| RabbitMQ | RabbitMQ | Kubernetes, namespace `sless` (позже) |
|
| Container Registry | DockerHub (`naeel/`) | внешний |
|
||||||
| S3 | Ceph (облачный) | `ceph.tst.nubes.ru` |
|
| Builder | kaniko (k8s Job) | namespace пользователя |
|
||||||
| Container Registry | Внутренний registry кластера | namespace `registry` |
|
| Функции (HTTP) | k8s Deployment + Service | namespace пользователя |
|
||||||
| Функции пользователей | k8s Jobs/Deployments | namespace `sless-fn-{id}` |
|
| Функции (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**.
|
||||||
|
|
||||||
```
|
```
|
||||||
Пользователь
|
JWT токен (Bearer)
|
||||||
│
|
└─► JWT.sub (строка "0199e325-1cdf-7cda-9319-e5302a85e291")
|
||||||
▼
|
└─► SHA256(sub) → первые 8 байт → hex → "sless-{16 hex символов}"
|
||||||
REST API (Go) ←── Terraform provider
|
└─► namespace = "sless-cdd874dfa31ba6ca"
|
||||||
│
|
|
||||||
├── PostgreSQL — метаданные функций, версии, логи вызовов
|
|
||||||
├── S3 (Ceph) — хранение кода (zip архивы)
|
|
||||||
├── Redis — кеш, rate limiting
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Builder — получает zip из S3, собирает Docker образ, пушит в registry
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Runner (k8s) — деплоит функцию как Job/Deployment в k8s
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
RabbitMQ — async вызовы, cron triggers (v2)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- 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 облака.
|
### Оператор (REST API)
|
||||||
Terraform provider передаёт его в заголовке `Authorization: Bearer <token>`.
|
- Bearer JWT в заголовке Authorization
|
||||||
Keycloak не используется.
|
- Проверяется структура 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 кластер
|
## Kubernetes кластер
|
||||||
|
|
||||||
Сейчас используется существующий кластер (временно).
|
Сейчас используется существующий кластер (временный).
|
||||||
Планируется переезд на новый кластер — манифесты переносятся без изменений.
|
Планируется переезд на новый кластер — манифесты переносятся без изменений.
|
||||||
|
|
||||||
Ноды существующего кластера:
|
Ноды:
|
||||||
- `wheel-control-plane-fm9sr` — control-plane
|
- wheel-control-plane-fm9sr — control-plane
|
||||||
- `wheel-workers-tv4qr-r45xs` — worker
|
- wheel-workers-tv4qr-r45xs — worker
|
||||||
- `wheel-workers-tv4qr-x8xw7` — worker
|
- wheel-workers-tv4qr-x8xw7 — worker
|
||||||
|
|
||||||
Ingress: nginx, external IP `5.172.178.182`
|
Ingress: nginx, external IP 5.172.178.182
|
||||||
Storage: rawfile CSI (local-path, default)
|
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 |
|
||||||
|
|||||||
@@ -399,3 +399,124 @@ if h.Validator != nil {
|
|||||||
- False positives: пользователь получит 400 с причиной, может обратиться в support.
|
- False positives: пользователь получит 400 с причиной, может обратиться в support.
|
||||||
- Soft-fail при недоступности LLM: security degraded, но деплой работает.
|
- Soft-fail при недоступности LLM: security degraded, но деплой работает.
|
||||||
- Prompt не идеален: LLM не ловит всё. Это дополнительный слой, не единственный.
|
- 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`.
|
||||||
|
|||||||
@@ -129,3 +129,41 @@
|
|||||||
| 5 | Pre-warm для cron триггеров | ⏳ | TriggerSpec.PreWarmSeconds — поле есть, логика не реализована |
|
| 5 | Pre-warm для cron триггеров | ⏳ | TriggerSpec.PreWarmSeconds — поле есть, логика не реализована |
|
||||||
| 11 | trigger.enabled | ✅ | enabled=false → Deployment replicas=0, in-place update через PATCH |
|
| 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 |
|
| 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 | Низкий |
|
||||||
|
|||||||
Reference in New Issue
Block a user