docs: document all changes and web-console plan

- doc/progress.md: funcs global service v0.1.x, URLs, next steps
- doc/decisions/log.md: storage architecture, funcs service decision, web-console plan
- doc/api/design.md: current + planned endpoints (/source, PATCH trigger)
- doc/architecture/overview.md: funcs-service component, S3 storage diagram
This commit is contained in:
Naeel
2026-03-18 16:38:18 +03:00
parent 38bb494ed5
commit a3528ff4fe
8 changed files with 1490 additions and 8 deletions
+35 -6
View File
@@ -1,5 +1,7 @@
# API Design # API Design
Последнее обновление: 2026-03-18
## Базовый URL ## Базовый URL
``` ```
@@ -7,15 +9,37 @@ http://<operator-host>:9090/v1
``` ```
Локально: `http://localhost:9090/v1` Локально: `http://localhost:9090/v1`
В кластере (будущее): `http://sless-operator.sless.svc.cluster.local:9090/v1` В кластере: `http://sless-operator.sless.svc.cluster.local:9090/v1`
Публично: `https://sless.kube5s.ru/v1/...` (через Ingress)
**Реализовано** (текущий namespace-aware формат): **Реализованные эндпоинты:**
``` ```
/v1/namespaces/{namespace}/functions[/{name}] GET /v1/namespaces/{ns}/functions
/v1/namespaces/{namespace}/functions/{name}/upload POST /v1/namespaces/{ns}/functions
/v1/namespaces/{namespace}/triggers[/{name}] GET /v1/namespaces/{ns}/functions/{name}
/v1/namespaces/{namespace}/functions/{name}/invocations DELETE /v1/namespaces/{ns}/functions/{name}
POST /v1/namespaces/{ns}/functions/{name}/upload
GET /v1/namespaces/{ns}/triggers
POST /v1/namespaces/{ns}/triggers
GET /v1/namespaces/{ns}/triggers/{name}
DELETE /v1/namespaces/{ns}/triggers/{name}
GET /v1/namespaces/{ns}/functions/{name}/invocations
```
**Запланированные эндпоинты (ветка feat/web-console):**
```
GET /v1/namespaces/{ns}/functions/{name}/source ← НОВЫЙ: код из S3 zip
PATCH /v1/namespaces/{ns}/triggers/{name} ← НОВЫЙ: enable/disable триггера
```
**Глобальный сервис funcs (не оператор):**
```
GET https://sless.kube5s.ru/funcs/<namespace> ← без токена, plain text / HTML
GET https://sless.kube5s.ru/funcs?token=<jwt> ← с токеном
GET https://sless.kube5s.ru/health ← liveness probe
``` ```
## Аутентификация ## Аутентификация
@@ -24,6 +48,11 @@ http://<operator-host>:9090/v1
Authorization: Bearer <cloud-token> Authorization: Bearer <cloud-token>
``` ```
Токен — JWT от `auth-api`. Middleware в операторе:
1. Извлекает `sub` из payload (без проверки подписи — доверяет Ingress)
2. Вычисляет namespace: `SHA256(sub)[:8]` hex → `sless-{16 hex символов}`
3. Проверяет что запрошенный `{namespace}` совпадает с вычисленным
## Ресурсы ## Ресурсы
### Functions ### Functions
+40 -1
View File
@@ -1,6 +1,6 @@
# Архитектура системы # Архитектура системы
Последнее обновление: 2026-03-11 (v0.1.22) Последнее обновление: 2026-03-18 (v0.1.33 + funcs-service v0.1.3)
## Общее описание ## Общее описание
@@ -13,6 +13,7 @@ Managed Serverless Functions Service для облачного провайде
| Компонент | Технология | Где запущен | | Компонент | Технология | Где запущен |
|-----------|-----------|-------------| |-----------|-----------|-------------|
| Operator (API + Controllers) | Go (controller-runtime) | Kubernetes, namespace `sless` | | Operator (API + Controllers) | Go (controller-runtime) | Kubernetes, namespace `sless` |
| funcs-service (глобальная консоль) | Go (net/http) | Kubernetes, namespace `sless` |
| PostgreSQL | PostgreSQL 16 | Kubernetes, namespace `sless` | | PostgreSQL | PostgreSQL 16 | Kubernetes, namespace `sless` |
| S3 | Ceph (облачный) | `s3.msk-1.ngcloud.ru` | | S3 | Ceph (облачный) | `s3.msk-1.ngcloud.ru` |
| Container Registry | DockerHub (`naeel/`) | внешний | | Container Registry | DockerHub (`naeel/`) | внешний |
@@ -25,6 +26,44 @@ Managed Serverless Functions Service для облачного провайде
> Redis и RabbitMQ — отложены до v2. > Redis и RabbitMQ — отложены до v2.
## Компонент: funcs-service
Глобальный HTTP сервис — **одна копия** на весь кластер, для всех пользователей.
```
User Browser / curl
└─► https://sless.kube5s.ru/funcs/<namespace>
└─► nginx Ingress (sless-funcs-ingress)
└─► sless-funcs-service:8090 (namespace sless)
└─► http://sless-operator.sless.svc.cluster.local:9090/v1/...
```
**Файлы:**
- `services/funcs/main.go` — логика
- `services/funcs/Dockerfile` — multi-stage Go → alpine
- `deployments/k8s/funcs-service.yaml` — Deployment + Service + Ingress
**Env vars сервиса:**
| Переменная | Значение |
|-----------|---------|
| `SLESS_OPERATOR_URL` | `http://sless-operator.sless.svc.cluster.local:9090` |
| `SLESS_EXTERNAL_URL` | `https://sless.kube5s.ru` |
| `SLESS_EXCLUDE` | `event-writer,event-monitor,event-cleaner` |
| `SLESS_SERVICE_TOKEN` | JWT токен (задаётся через `kubectl set env`, не в git) |
## Хранение кода функций в S3
```
Terraform source_dir (локально)
└─► zip → POST /upload → builder.PrepareContext()
├─► functions/{ns}/{name}/{ts}.zip ← ИСХОДНЫЙ КОД пользователя
└─► contexts/{ns}/{name}/{ts}.tar.gz ← BUILD CONTEXT для kaniko
└─► Function CRD: spec.s3Key = "contexts/..."
└─► контроллер → kaniko Job → Docker image
```
Для web-консоли: `GET /source` читает `functions/{ns}/{name}/{ts}.zip` напрямую.
## Изоляция пользователей — Namespace per user ## Изоляция пользователей — Namespace per user
Каждый пользователь облака получает **отдельный k8s namespace**. Каждый пользователь облака получает **отдельный k8s namespace**.
+57
View File
@@ -1,5 +1,62 @@
# Решения и обоснования # Решения и обоснования
## 2026-03-18 — Архитектура: funcs как глобальный сервис, web-консоль
### Хранение кода функций
S3 (minio внутри кластера) хранит **два артефакта** на каждый upload:
```
functions/{namespace}/{name}/{timestamp}.zip ← ИСХОДНЫЙ КОД (zip пользователя)
contexts/{namespace}/{name}/{timestamp}.tar.gz ← BUILD CONTEXT для kaniko (zip + Dockerfile)
```
Function CRD хранит `spec.s3Key` — указывает на `contexts/...` (build context).
Из него можно восстановить путь к исходному zip:
`contexts/{ns}/{name}/{ts}.tar.gz``functions/{ns}/{name}/{ts}.zip`
Поэтому для отображения кода в веб-консоли **не нужно ничего менять в CRD**:
достаточно нового эндпоинта `GET /source` который читает zip из S3.
### Решение: funcs как глобальный Go сервис вместо per-user terraform
**Было:** `sless_function.funcs_list` + `sless_trigger.funcs_list_http` в `examples/POSTGRES/resources.tf`
— Для каждого пользователя terraform создавал отдельный pod функции
— Требовал `api_token`, `SLESS_NAMESPACE` как env vars в terraform
— Не масштабируется: N пользователей = N лишних pod'ов
**Стало:** `services/funcs/main.go` — один Go HTTP сервис в namespace `sless`
— Деплоится один раз через `deployments/k8s/funcs-service.yaml`
— Принимает JWT токен → извлекает `sub``SHA256[:8]` → namespace
— URL без токена: `/funcs/<namespace>` (namespace не секрет — виден в URL каждой функции)
`SLESS_SERVICE_TOKEN` задаётся через `kubectl set env` (не хранится в git)
### Про будущую синхронизацию terraform-папок с кластером
Terraform уже работает по схеме: `source_dir` → zip → `POST /upload` → S3.
Обратная синхронизация (кластер → локальная папка): скачать zip из S3 → распаковать в `source_dir`.
Никаких структурных изменений не потребует. Реализовывать ПОСЛЕ web-консоли.
### Архитектура web-консоли (план, ветка feat/web-console)
**Принцип:** минимум изменений в операторе, максимум логики в `sless-funcs-service`.
**Два новых эндпоинта в операторе:**
| Метод | Путь | Что делает |
|-------|------|-----------|
| GET | `/v1/namespaces/{ns}/functions/{name}/source` | Читает zip из S3 → JSON `[{name, content}]` |
| PATCH | `/v1/namespaces/{ns}/triggers/{name}` | `{"enabled": bool}` → обновляет Trigger CRD |
**`sless-funcs-service` — HTML режим:**
- Если запрос из браузера (`Accept: text/html`) → отдаёт HTML страницу
- Список функций — аккордеон; при раскрытии `fetch(/source)` подгружает файлы
- Подсветка синтаксиса: `highlight.js` с CDN (не требует сборки)
- Кнопки ▶ Старт / ■ Стоп → `PATCH /triggers/{name}` через fetch
- `text/plain` ответ для curl/CLI остаётся без изменений
---
## 2026-03-18 — Смена sless API endpoint: sless-api.kube5s.ru → sless.kube5s.ru ## 2026-03-18 — Смена sless API endpoint: sless-api.kube5s.ru → sless.kube5s.ru
**Решение:** Оператор sless доступен по `https://sless.kube5s.ru` (не `sless-api.kube5s.ru`). Все examples, deployments и ConfigMap обновлены. **Решение:** Оператор sless доступен по `https://sless.kube5s.ru` (не `sless-api.kube5s.ru`). Все examples, deployments и ConfigMap обновлены.
+159
View File
@@ -0,0 +1,159 @@
# API Design
Последнее обновление: 2026-03-18
## Базовый URL
```
http://<operator-host>:9090/v1
```
Локально: `http://localhost:9090/v1`
В кластере: `http://sless-operator.sless.svc.cluster.local:9090/v1`
Публично: `https://sless.kube5s.ru/v1/...` (через Ingress)
**Реализованные эндпоинты:**
```
GET /v1/namespaces/{ns}/functions
POST /v1/namespaces/{ns}/functions
GET /v1/namespaces/{ns}/functions/{name}
DELETE /v1/namespaces/{ns}/functions/{name}
POST /v1/namespaces/{ns}/functions/{name}/upload
GET /v1/namespaces/{ns}/triggers
POST /v1/namespaces/{ns}/triggers
GET /v1/namespaces/{ns}/triggers/{name}
DELETE /v1/namespaces/{ns}/triggers/{name}
GET /v1/namespaces/{ns}/functions/{name}/invocations
```
**Запланированные эндпоинты (ветка feat/web-console):**
```
GET /v1/namespaces/{ns}/functions/{name}/source ← НОВЫЙ: код из S3 zip
PATCH /v1/namespaces/{ns}/triggers/{name} ← НОВЫЙ: enable/disable триггера
```
**Глобальный сервис funcs (не оператор):**
```
GET https://sless.kube5s.ru/funcs/<namespace> ← без токена, plain text / HTML
GET https://sless.kube5s.ru/funcs?token=<jwt> ← с токеном
GET https://sless.kube5s.ru/health ← liveness probe
```
## Аутентификация
```
Authorization: Bearer <cloud-token>
```
Токен — JWT от `auth-api`. Middleware в операторе:
1. Извлекает `sub` из payload (без проверки подписи — доверяет Ingress)
2. Вычисляет namespace: `SHA256(sub)[:8]` hex → `sless-{16 hex символов}`
3. Проверяет что запрошенный `{namespace}` совпадает с вычисленным
## Ресурсы
### Functions
| Метод | Путь | Описание |
|-------|------|----------|
| GET | /functions | Список функций |
| POST | /functions | Создать функцию |
| GET | /functions/{id} | Получить функцию |
| PUT | /functions/{id} | Обновить функцию |
| DELETE | /functions/{id} | Удалить функцию |
### Versions (код функции)
| Метод | Путь | Описание |
|-------|------|----------|
| GET | /functions/{id}/versions | Список версий |
| POST | /functions/{id}/versions | Загрузить новый код (multipart zip) |
| GET | /functions/{id}/versions/{ver} | Получить версию |
| POST | /functions/{id}/versions/{ver}/activate | Активировать версию |
### Triggers
| Метод | Путь | Описание |
|-------|------|----------|
| GET | /functions/{id}/triggers | Список триггеров |
| POST | /functions/{id}/triggers | Создать триггер (HTTP/Cron) |
| DELETE | /functions/{id}/triggers/{tid} | Удалить триггер |
### Invocations (вызов и логи)
| Метод | Путь | Описание |
|-------|------|----------|
| POST | /functions/{id}/invoke | Синхронный вызов |
| GET | /functions/{id}/invocations | История вызовов |
| GET | /functions/{id}/invocations/{iid} | Детали вызова + логи |
## Upload endpoint
```
POST /v1/namespaces/{namespace}/functions/{name}/upload
Content-Type: multipart/form-data
Authorization: Bearer <token>
field: code = <zip-file>
```
Процесс:
1. Принимает zip (max 32MB)
2. Распаковывает zip
3. Генерирует `Dockerfile` (`FROM naeel/sless-runtime-{runtime}:latest\nCOPY . /app/function/`)
4. Перепаковывает в `tar.gz` (kaniko требует tar format)
5. Загружает в S3: `contexts/{ns}/{name}/{timestamp}.tar.gz`
6. Обновляет `fn.Spec.S3Key` → контроллер видит изменение и запускает kaniko Job
Ответ `200 OK`:
```json
{"message": "build queued", "phase": "Pending", "s3_key": "contexts/..."}
```
## Поддерживаемые runtime (v1)
- `python3.11` — реализован и протестирован
- `go1.21` — планируется
- `nodejs20` — планируется
## Модель Function
```json
{
"id": "fn-uuid",
"name": "my-function",
"description": "...",
"runtime": "python3.11",
"entrypoint": "handler.handle",
"memory_mb": 128,
"timeout_sec": 30,
"env_vars": {"KEY": "value"},
"active_version": "1",
"status": "active",
"created_at": "...",
"updated_at": "..."
}
```
## Модель Trigger
```json
{
"id": "tr-uuid",
"type": "http",
"url": "https://sless.api.ngcloud.ru/invoke/fn-uuid",
"created_at": "..."
}
```
```json
{
"id": "tr-uuid",
"type": "cron",
"schedule": "0 * * * *",
"created_at": "..."
}
```
+184
View File
@@ -2,6 +2,190 @@
> Сюда записываем проблемы с которыми столкнулись и как их решили. > Сюда записываем проблемы с которыми столкнулись и как их решили.
---
## 2026-03-17 — Баг 3: PodLogOptions compile error (v0.1.31)
**Проблема:** Оператор не компилировался. Ошибка:
```
controllers/functionjob_controller.go: unknown field Stdout in corev1.PodLogOptions
controllers/functionjob_controller.go: unknown field Stderr in corev1.PodLogOptions
```
**Причина:** `corev1.PodLogOptions{}` в k8s API не имеет полей `Stdout` и `Stderr` — это поля из `corev1.ContainerState`. Код выглядел так:
```go
req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{
Stdout: true, // не существует
Stderr: true, // не существует
})
```
**Решение:** Убрать несуществующие поля. `GetLogs` по умолчанию возвращает stdout+stderr без дополнительных флагов:
```go
req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{})
```
**Файл:** `controllers/functionjob_controller.go`, функция `getJobPodOutput`
**Версия:** исправлено в `naeel/sless-operator:v0.1.31`
---
## 2026-03-17 — Главная причина провала POSTGRES example: неправильный nubes_endpoint
### Симптом
`terraform apply` для `examples/POSTGRES` падал с `"job failed, check pod logs"`. При этом `examples/simple-python` и `examples/hello-node` работали нормально на тест-стенде.
### Корневая причина
В `examples/POSTGRES/main.tf` в блоке `provider "sless"` был указан **продовый** endpoint Nubes API:
```hcl
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
token = var.api_token
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1" # ← ПРОД
}
```
При этом `provider "nubes"` уже указывал на тест:
```hcl
provider "nubes" {
api_token = var.api_token
api_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm" # ← тест ✓
}
```
Из-за этого `sless_function` и `sless_job` стучались в **продовый** Nubes API, где у тестового пользователя другой namespace и другой кластер. Postgres и pod запускались в контексте прода, а не тест-стенда. Поды не могли подключиться к базе.
### Фикс
```hcl
# examples/POSTGRES/main.tf
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
token = var.api_token
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1" # ← тест
}
```
Аналогично исправлено в `examples/simple-python/main.tf`.
### Почему долго искали
Долгое расследование ушло на "split-brain" — API-созданные FunctionJob'ы были невидимы через `kubectl get functionjobs -A`. Это объяснялось тем что:
1. Job завершался (Failed) за ~5 секунд
2. Между завершением и опросом kubectl объект уже мог быть очищен
3. Параллельно в кластере фигурировал другой namespace (`sless-ffd1f598c169b0ae` — прод) vs тест-namespace
На самом деле контроллер работал корректно. Проблема была исключительно в `nubes_endpoint`.
### Урок
**Всегда проверять**: когда в `main.tf` два провайдера (`nubes` + `sless`) — у обоих endpoint'ы должны указывать на **одну среду**. Смешивание прод/тест endpoint'ов в одном apply даёт непредсказуемые результаты.
---
## 2026-03-17 — Ошибка агента: локальный запуск terraform
**Проблема:** Агент запускал `terraform apply` **локально** (`/home/naeel/remote_dev/sless/examples/...`) вместо запуска на удалённом сервере `naeel@5.172.178.213`.
**Почему неправильно:**
- Провайдер `terra.k8c.ru/naeel/sless` установлен только на удалённом сервере
- Локальный terraform не имеет доступа к k8s кластеру напрямую
- Потенциально затрагивал продовые API из локальной сети
**Правило:** Все `terraform init/plan/apply/destroy` для `examples/` — только через SSH на `naeel@5.172.178.213`:
```bash
ssh -i /home/naeel/remote_dev/common/id_ed25519.txt naeel@5.172.178.213 \
'cd /home/naeel/terra/sless/examples/<example> && terraform apply -auto-approve -no-color'
```
---
## 2026-03-17 — FunctionJob всегда "job failed, check pod logs" — расследование и два бага
### Хронология расследования
**Симптом:** `terraform apply` на `examples/POSTGRES` завершается ошибкой:
```
job failed: job failed, check pod logs: kubectl logs -n sless-fn-sless-ffd1f598c169b0ae -l functionjob=pg-create-table-job-main-v12
```
Команда из сообщения — ничего не выдаёт (под не найден).
---
**Ошибка агента №1 — SQL-диагностика вместо анализа кода**
Первая реакция — запустить диагностические поды в кластере с psycopg2 чтобы проверить подключение к PostgreSQL. Это было **неправильно**: проблема не в подключении, а в том что job не запускался вообще. Пользователь остановил — "ПРИ ЧЁМ тут SQL запросы???".
---
**Ошибка агента №2 — не читал собственный код**
Раньше читались логи оператора и kubectl describe — но не исходный код контроллеров. Пользователь указал: "мы пишем ВСЁ сами, смотри в код". После прочтения `functionjob_controller.go` и `functions.go` картина сложилась за одно чтение.
---
### Баг 1 — k8s label `job-name` удалён в 1.27+
**Файл:** `controllers/functionjob_controller.go`, функция `getJobPodOutput`
**Код до:**
```go
pods, err := kube.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
LabelSelector: "job-name=" + jobName,
})
if err != nil || len(pods.Items) == 0 {
return "completed successfully" // ← сюда всегда попадали
}
```
**Причина:** В k8s 1.27 лейбл `job-name` на pod-ах был deprecated, в 1.32+ удалён полностью. У нас кластер **1.34.1**. Поэтому `List` возвращал 0 подов всегда → fallback `"completed successfully"` → при `phase=Failed` финальное сообщение `"job failed, check pod logs: ..."`.
Логи реального сбоя никогда не попадали в `status.Message` FunctionJob, поэтому **корневая ошибка Python-функции была невидима**.
**Как нашли:** Проверили `kubectl get pod -l "batch.kubernetes.io/job-name"` — вернул поды. `kubectl get pod -l "job-name"` — не вернул. Версия кластера: `v1.34.1` (kubectl `v1.35.2`).
**Фикс:** Использовать собственный лейбл `functionjob=<fj.Name>`, который мы сами выставляем на PodTemplate и который работает независимо от версии k8s.
---
**Также:** В строке 211 подсказка для пользователя тоже использовала устаревший лейбл:
```go
"job failed, check pod logs: kubectl logs -n " + job.Namespace + " -l job-name=" + job.Name
```
Исправлено на `functionjob=<fj.Name>` — теперь команда реально работает.
---
### Баг 2 — Split-brain cached client при CreateFunction
**Файл:** `internal/api/handler/functions.go`, функция `CreateFunction`
**Проявление:** API возвращает `409 "function already exists"`, а `kubectl get function -A` функцию не видит. `terraform state rm sless_function.*` не помогает — следующий `terraform apply` снова получает 409.
**Причина:** `h.K8s` в handler — это **cached client** controller-runtime. Цепочка:
1. Предыдущий `terraform destroy` вызвал `DELETE /functions/pg-create-table-runner`
2. Handler вызвал `h.K8s.Delete(ctx, fn)` — объект удалён из **etcd**
3. Кеш controller-runtime обновляется асинхронно (informer watch). Несколько секунд объект ещё жив в памяти оператора
4. Следующий `terraform apply``POST /functions``h.K8s.Create(ctx, fn)``IsAlreadyExists` (кеш ещё видит объект)
5. Код проверяет `phase == Failed` — но объект в кеше в фазе `Ready` → уходит в 409 навсегда
`kubectl get function` шёл **мимо кеша** (прямо в k8s API) → NotFound. API handler шёл **через кеш** → AlreadyExists. Поэтому они показывали разные результаты.
**Фикс:** При `IsAlreadyExists` делать `uncached Get` (через `client.ObjectKey` напрямую). Если объект реально не найден в etcd (`IsNotFound`) — значит кеш устарел, смело создаём заново. Если найден — возвращаем 409 как обычно (объект реально существует).
---
### Итог
| # | Баг | Файл | Строка | Тип |
|---|-----|------|--------|-----|
| 1 | `LabelSelector: "job-name="` deprecated k8s 1.27+ | `controllers/functionjob_controller.go` | 311 | Совместимость |
| 1b | Подсказка `kubectl logs -l job-name=` тоже устарела | `controllers/functionjob_controller.go` | 211 | UX |
| 2 | Cached client → perpetual 409 при CreateFunction | `internal/api/handler/functions.go` | 117-133 | Race condition |
## Шаблон записи ## Шаблон записи
``` ```
+763
View File
@@ -0,0 +1,763 @@
# Решения и обоснования
## 2026-03-18 — Архитектура: funcs как глобальный сервис, web-консоль
### Хранение кода функций
S3 (minio внутри кластера) хранит **два артефакта** на каждый upload:
```
functions/{namespace}/{name}/{timestamp}.zip ← ИСХОДНЫЙ КОД (zip пользователя)
contexts/{namespace}/{name}/{timestamp}.tar.gz ← BUILD CONTEXT для kaniko (zip + Dockerfile)
```
Function CRD хранит `spec.s3Key` — указывает на `contexts/...` (build context).
Из него можно восстановить путь к исходному zip:
`contexts/{ns}/{name}/{ts}.tar.gz``functions/{ns}/{name}/{ts}.zip`
Поэтому для отображения кода в веб-консоли **не нужно ничего менять в CRD**:
достаточно нового эндпоинта `GET /source` который читает zip из S3.
### Решение: funcs как глобальный Go сервис вместо per-user terraform
**Было:** `sless_function.funcs_list` + `sless_trigger.funcs_list_http` в `examples/POSTGRES/resources.tf`
— Для каждого пользователя terraform создавал отдельный pod функции
— Требовал `api_token`, `SLESS_NAMESPACE` как env vars в terraform
— Не масштабируется: N пользователей = N лишних pod'ов
**Стало:** `services/funcs/main.go` — один Go HTTP сервис в namespace `sless`
— Деплоится один раз через `deployments/k8s/funcs-service.yaml`
— Принимает JWT токен → извлекает `sub``SHA256[:8]` → namespace
— URL без токена: `/funcs/<namespace>` (namespace не секрет — виден в URL каждой функции)
`SLESS_SERVICE_TOKEN` задаётся через `kubectl set env` (не хранится в git)
### Про будущую синхронизацию terraform-папок с кластером
Terraform уже работает по схеме: `source_dir` → zip → `POST /upload` → S3.
Обратная синхронизация (кластер → локальная папка): скачать zip из S3 → распаковать в `source_dir`.
Никаких структурных изменений не потребует. Реализовывать ПОСЛЕ web-консоли.
### Архитектура web-консоли (план, ветка feat/web-console)
**Принцип:** минимум изменений в операторе, максимум логики в `sless-funcs-service`.
**Два новых эндпоинта в операторе:**
| Метод | Путь | Что делает |
|-------|------|-----------|
| GET | `/v1/namespaces/{ns}/functions/{name}/source` | Читает zip из S3 → JSON `[{name, content}]` |
| PATCH | `/v1/namespaces/{ns}/triggers/{name}` | `{"enabled": bool}` → обновляет Trigger CRD |
**`sless-funcs-service` — HTML режим:**
- Если запрос из браузера (`Accept: text/html`) → отдаёт HTML страницу
- Список функций — аккордеон; при раскрытии `fetch(/source)` подгружает файлы
- Подсветка синтаксиса: `highlight.js` с CDN (не требует сборки)
- Кнопки ▶ Старт / ■ Стоп → `PATCH /triggers/{name}` через fetch
- `text/plain` ответ для curl/CLI остаётся без изменений
---
## 2026-03-18 — Смена sless API endpoint: sless-api.kube5s.ru → sless.kube5s.ru
**Решение:** Оператор sless доступен по `https://sless.kube5s.ru` (не `sless-api.kube5s.ru`). Все examples, deployments и ConfigMap обновлены.
**Причина:** При пересоздании кластера DNS-запись `sless-api.kube5s.ru` не была обновлена — она указывала на IP `5.172.178.182` (старый, мёртвый кластер). Ingress нового кластера закреплён на `185.247.187.147`. Отдельная запись `sless.kube5s.ru` уже корректно указывала на `185.247.187.147`.
TLS handshake timeout возникал потому что старый IP принимал TCP:443, но не завершал TLS (nginx жив, бэкенд мёртв). Go HTTP клиент ждал системный таймаут (~90s) и повторял бесконечно.
**Изменения:**
- `deployments/k8s/operator.yaml`: `EXTERNAL_URL`, `INGRESS_HOST`, ingress host → `sless.kube5s.ru`
- ConfigMap `sless-operator-config` в кластере: `EXTERNAL_URL` обновлён через `kubectl patch`
- Ingress `sless-operator` в кластере: host + TLS secret → `sless.kube5s.ru`
- Все `examples/**/main.tf`: `endpoint = "https://sless.kube5s.ru"`
**Правило:** При пересоздании кластера — первым делом проверять соответствие DNS → ingress IP.
---
## 2026-03-17 — Разделение prod/test endpoint'ов в examples/
**Решение:** Все `examples/` ОБЯЗАНЫ использовать `deck-api-test.ngcloud.ru` для обоих провайдеров: `nubes` и `sless` (`nubes_endpoint`). Продовый `deck-api.ngcloud.ru` — только для реальных клиентов.
**Причина:** Смешивание prod и test endpoint'ов в одном `terraform apply` приводит к тому что ресурсы создаются в разных средах. `sless_function`/`sless_job` могут получать данные (PGHOST, credentials) из прода, а pod запускается в тест-кластере — и не может достучаться до хоста.
**Правило для `main.tf` в examples:**
```hcl
provider "nubes" {
api_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm"
}
provider "sless" {
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
}
```
---
## 2026-03-17 — terraform apply только на удалённом сервере
**Решение:** `terraform init/plan/apply/destroy` для `examples/` — исключительно через SSH на сервере `naeel@5.172.178.213`. Локальный запуск запрещён.
**Причина:**
1. Провайдер `terra.k8c.ru/naeel/sless` кэширован только на удалённом сервере
2. Локальный terraform не имеет сетевого доступа к k8s кластеру и внутренним кластерным адресам (например PGHOST вида `*.svc.cluster.local`)
3. Случайный локальный запуск с prod токенами может затронуть боевую среду
**Как запускать:**
```bash
# Сначала синхронизировать изменения:
rsync -av -e "ssh -i /home/naeel/remote_dev/common/id_ed25519.txt" \
/home/naeel/remote_dev/sless/examples/<example>/ \
naeel@5.172.178.213:/home/naeel/terra/sless/examples/<example>/
# Затем запускать на remote:
ssh -i /home/naeel/remote_dev/common/id_ed25519.txt naeel@5.172.178.213 \
'cd /home/naeel/terra/sless/examples/<example> && terraform apply -auto-approve -no-color'
```
---
## 2026-03-06 — Отдельная репа для сервиса
**Решение:** Serverless service в отдельной репе, не вместе с Terraform provider.
**Причина:** Разные зоны ответственности, разные релизы, потенциально разные команды.
---
## 2026-03-06 — Один бинарник для v1
**Решение:** Один Go бинарник вместо микросервисов.
**Причина:** Нагрузки изначально нет. Проще деплоить, проще отлаживать. Разделим при необходимости.
---
## 2026-03-06 — Аутентификация через облачный токен
**Решение:** Использовать Bearer token облака, без Keycloak.
**Причина:** Terraform provider уже работает с токенами облака. Keycloak — лишняя зависимость для v1.
---
## 2026-03-06 — S3 облачный, остальное в кубере
**Решение:** S3 (Ceph) использовать облачный (`ceph.tst.nubes.ru`), PostgreSQL/Redis — в кластере.
**Причина:** S3 имеет внешний доступ и уже готов. Для PostgreSQL/Redis сетевого связывания с облаком пока нет — настраивается через devops облака.
---
## 2026-03-06 — Текущий кластер для разработки
**Решение:** Использовать существующий k8s кластер (namespace `sless`), потом перенести на новый.
**Причина:** Новый кластер ещё не готов. Изоляция через namespace — безопасно для существующих сервисов.
---
## 2026-03-06 — RabbitMQ откладываем
**Решение:** В v1 только HTTP и Cron триггеры. RabbitMQ/event triggers — в v2.
**Причина:** Упрощение первой итерации.
---
## 2026-03-07 — DockerHub вместо внутреннего registry
**Решение:** Образы функций и runtime базовые образы публикуются на DockerHub (user `naeel`).
**Причина:** Namespace `registry` в кластере — это Apache NiFi Registry (NOT Docker). Отдельный Docker registry не поднят. DockerHub доступен и достаточен для разработки.
---
## 2026-03-07 — Terraform провайдер sless — отдельный модуль в той же репе
**Решение:** `terraform/provider/` — независимый Go-модуль внутри репы `sless`.
**Причина:** Удобно держать рядом с кодом оператора во время разработки.
**Важно:** `provider "sless"` и `provider "nubes"` — это **два отдельных независимых провайдера**. Объединять их нельзя:
- разные зоны ответственности (`nubes` — облачная инфраструктура, `sless` — serverless функции)
- разные релизные циклы
- разные команды в будущем
Пользователь использует оба провайдера вместе в одном `.tf` файле, но это не означает что они должны быть одним бинарником.
---
## 2026-03-07 — WaitReady в Terraform провайдере при создании функции
**Решение:** После `UploadCode` провайдер ждёт `phase=Ready` (polling каждые 5 сек, таймаут 5 мин).
**Причина:** Kaniko-сборка занимает ~1 минуту. Без ожидания `terraform apply` завершился бы с `phase=Building` в state, что неверно отображало бы реальное состояние ресурса.
---
## 2026-03-07 — code_hash для детектирования изменений кода функции
**Решение:** Атрибут `code_hash` в `sless_function` — пользователь задаёт через `filemd5("./handler.zip")`. Изменение hash → провайдер перезагружает zip и запускает пересборку.
**Причина:** Terraform не отслеживает содержимое файлов автоматически. Это стандартный паттерн (аналогично `aws_lambda_function.source_code_hash`).
---
## 2026-03-07 — Scale-to-zero откладываем до v2
**Решение:** В v1 функции работают как Deployment с постоянно живым подом (always-on). Scale-to-zero — в v2 через KEDA HTTP Add-on.
**Причина:** Scale-to-zero меняет архитектуру контроллера и routing. Для MVP это несоразмерная сложность. Пользователь может управлять ресурсами вручную через `replicas = 0/1/N` (планируется в v1.1).
**v2 план:** Заменить Deployment на `HTTPScaledObject` (KEDA), минимальные реплики = 0. KEDA буферизует запросы во время cold start (~1-3 сек).
---
## 2026-03-07 — replicas как ручное управление масштабом (TODO v1.1)
**Решение:** Добавить поле `replicas *int32` в `FunctionSpec`. Пользователь задаёт через Terraform: `replicas = 0` (выключить), `replicas = 1` (включить), `replicas = N` (масштабировать).
**Причина:** Без этого функция жрёт ресурсы 24/7 даже если не нужна. Это минимальный механизм контроля потребления до реализации scale-to-zero.
---
## 2026-03-07 — PostgreSQL опционален для базового Function Hosting
**Решение:** Postgres нужен только для логов вызовов (`invocations`). Для базового деплоя функций — не нужен. Оператор работает без него (просто не пишет логи).
**Минимальные зависимости для production:** k8s кластер + S3 + Docker registry + Ingress.
## 2026-03-07 — Версионированные теги для runtime образов (не :latest)
**Решение:** Runtime базовые образы (`sless-runtime-python3.11`, `sless-runtime-nodejs20`) и образ оператора (`sless-operator`) тегируются по схеме `v<major>.<minor>.<patch>`. `:latest` не используется.
**Причина:**
- `:latest` приводит к непредсказуемому поведению: kaniko может взять старый кешированный образ, pod не перезапускается если `imagePullPolicy: IfNotPresent`.
- Версионированные теги дают явный контроль: при изменении runtime нужно обновить тег в `upload.go` → это принудительно пересобирает все функции с новым базовым образом.
- Аудит и откат: можно пинить конкретную версию runtime.
**Соглашение:**
- Runtime образы: `naeel/sless-runtime-{lang}:v{версия}` (например `v0.1.0`)
- Оператор: `naeel/sless-operator:v{версия}`
- При изменении runtime — инкрементировать минорную версию образа и обновить константу в `upload.go`
---
## 2026-03-07 — nodejs20 как второй поддерживаемый runtime
**Решение:** Добавлен nodejs20 runtime (`node:20-alpine` base, `server.js` HTTP wrapper, `exports.handle(event)`).
**Причина:** Node.js — стандарт для serverless (AWS Lambda, Vercel). Покрывает JS/TypeScript аудиторию. Паттерн идентичен python3.11: runtime image → kaniko → Deployment.
**Детали реализации:**
- `runtimes/nodejs20/server.js``http.createServer`, динамический `require(HANDLER_PATH)`
- Зависимости через `package.json``npm install --omit=dev` (аналог `requirements.txt``pip install`)
- `entrypoint` в HCL игнорируется для Node.js (всегда `handler.js` + `exports.handle`) — TODO: поддержать произвольный entrypoint в v1.1
---
## 2026-03-07 — FunctionJob CRD: одноразовые запуски функций
**Решение:** Добавлен `FunctionJob` CRD для одноразового запуска функции с произвольным JSON-событием.
**Причина:** Нужны sync-вызовы без HTTP — для батч-обработки, миграций, крон-задач через Terraform.
**Реализация:**
- `api/v1alpha1/job_types.go` — CRD: `FunctionRef`, `EventJSON`, phases: Pending/Running/Succeeded/Failed
- `controllers/functionjob_controller.go` — создаёт k8s Job, ждёт завершения, синхронизирует статус
- `internal/api/handler/jobs.go` — REST: CreateJob/GetJob/DeleteJob
- `terraform/provider/internal/resources/job_resource.go` — ресурс `sless_job`
- Настраиваемые таймауты: `build_timeout_sec` (sless_function), `wait_timeout_sec` (sless_job)
---
## 2026-03-07 — Прокси /fn/ вместо wildcard Ingress
**Проблема:** wildcard DNS `*.fn.kube5s.ru` недоступен (провайдер не позволяет).
**Решение:** HTTP-прокси внутри оператора — маршрут `GET|POST|... /fn/{namespace}/{name}` на `sless-api.kube5s.ru`.
**Реализация:**
- `internal/api/handler/invoke.go` — форвардит запрос к `http://{fn}.sless-fn-{ns}.svc.cluster.local:8080`
- `internal/api/router.go``/fn/` регистрируется **до** auth middleware, публично доступен; `/v1/` — по-прежнему с Bearer токеном (gorilla `Use()`)
- `internal/config/config.go` — новое поле `ExternalURL` (env `EXTERNAL_URL`)
- `controllers/trigger_controller.go` — если `ExternalURL` задан, `Trigger.Status.URL = ExternalURL/fn/{ns}/{name}`; иначе fallback: создаёт Ingress с поддоменом (прежнее поведение)
- `deployments/k8s/operator.yaml``EXTERNAL_URL=https://sless-api.kube5s.ru`
**URL функции:** `https://sless-api.kube5s.ru/fn/{namespace}/{name}`
**E2E:** `curl https://sless-api.kube5s.ru/fn/default/hello-node``{"message":"Hello, Naeel! (nodejs20)"}`
---
## 2026-03-08 — Lifecycle control: trigger.enabled + job.run_id
**Задача:** управление жизненным циклом ресурсов без удаления.
### trigger.enabled
**Проблема:** нет способа "заморозить" функцию без удаления Trigger/Function (
освобождение ресурсов под праздники, дебаггинг и т.д.).
**Решение:** `enabled bool` (по умолчанию `true`) в `TriggerSpec`.
- `enabled=false` → trigger_controller масштабирует Deployment функции до 0 реплик.
- Функция не принимает запросы, не потребляет CPU (pod не запущен).
- Изменение **не** пересоздаёт ресурс (нет RequiresReplace) — in-place через PATCH.
**Реализация:**
- `api/v1alpha1/trigger_types.go``Enabled bool` в TriggerSpec, `//+kubebuilder:default=true`
- `controllers/trigger_controller.go` — патчит Deployment replicas=0/1 в зависимости от Enabled
- `internal/api/handler/triggers.go``UpdateTrigger` handler (PATCH), поле `enabled` в request/response
- `internal/api/router.go``PATCH /v1/namespaces/{namespace}/triggers/{name}`
- `internal/client/client.go``TriggerUpdateRequest`, `UpdateTrigger()` метод
- `terraform/provider/internal/resources/trigger_resource.go` — атрибут `enabled` (Optional+Computed, default=true), реализован `Update` метод
### job.run_id
**Проблема:** нет способа создать FunctionJob "отложенным" — с явным контролем когда запускать.
Также нет механизма повторного запуска с сохранением структуры ресурса.
**Решение:** `run_id int64` (по умолчанию `0`) в `FunctionJobSpec`.
- `run_id=0` → FunctionJob создаётся в k8s, но k8s Job не запускается (phase=Skipped).
- `run_id>0` → запускает Job. Увеличение значения (1→2→3) = повторный запуск через пересоздание.
**Реализация:**
- `api/v1alpha1/job_types.go``RunID int64` в FunctionJobSpec, `//+kubebuilder:default=0`
- `controllers/functionjob_controller.go` — если RunID==0 → устанавливает phase=Skipped, return
- `internal/api/handler/jobs.go` — поле `run_id` в jobRequest/jobResponse
- `internal/client/client.go``RunID int64` в JobRequest/JobResponse
- `terraform/provider/internal/resources/job_resource.go` — атрибут `run_id` (RequiresReplace, default=0). Если run_id=0 → не ждёт завершения, phase=Skipped сразу в state.
**Версии:**
- operator: `naeel/sless-operator:v0.1.6`
- provider: `terra.k8c.ru/naeel/sless v0.1.4`
---
## 2026-03-08 — Переключение registry с Harbor на DockerHub
**Проблема:** Harbor (`pearlharbor.registryk8s.services.ngcloud.ru`) — внешний сервис облачного провайдера. Нестабилен: `/v2/` периодически зависает на 10+ секунд или возвращает 504. Kaniko не мог завершить push образа.
**Решение:** `REGISTRY_HOST=naeel` (DockerHub namespace). Образы функций пушатся как `naeel/sless-default-{namespace}-{name}:latest`.
**Реализация:**
- `deployments/k8s/operator.yaml` — configmap `REGISTRY_HOST: "naeel"`
- Secret `sless-registry-auth` уже содержал DockerHub credentials → дополнительных изменений не потребовалось
**Компромисс:** DockerHub — публичный registry. Образы функций пользователей публично видимы. Для production нужен приватный registry (Harbor, ECR, GCR и т.д.).
**Версии:**
- operator: оператор не пересобирался, только configmap
- commit: `b69f795`
---
## 2026-03-08 — FunctionJob polling вместо Owns watch
**Проблема:** `Owns(&batchv1.Job{})` в `SetupWithManager` не работает cross-namespace. Job создаётся в `sless-fn-{ns}`, FunctionJob — в user namespace. Watch никогда не срабатывал.
**Решение:** Убрать `Owns`. В `syncJobStatus` при Running статусе возвращать `ctrl.Result{RequeueAfter: 5 * time.Second}` — контроллер сам поллит k8s Job каждые 5 сек.
**Версии:**
- operator: `naeel/sless-operator:v0.1.10`
- commit: `461ac09`
---
## 2026-03-08 — code_hash: filesha256 вместо output_md5
**Проблема:** `hashicorp/archive v2.7.x` имеет баг: `output_md5` возвращает MD5 предыдущей версии zip. `output_sha` и `output_sha256` обновляются корректно.
**Решение:** `code_hash = filesha256("${path.module}/code/handler.js")` — хэшируется исходный файл напрямую.
**Правило проекта:** В `sless_function.code_hash` всегда использовать `filesha256(source_file)`, не `archive_file.output_md5`.
---
## 2026-03-08 — Rollout restart после kaniko build (imagePullPolicy + :latest)
**Проблема:** После успешной kaniko сборки pod не перезапускался — kubelet брал кешированный образ `:latest` (imagePullPolicy: IfNotPresent). Функция возвращала старый код.
**Решение:** В `ensureDeployment` при обновлении существующего Deployment проставляем аннотацию:
```go
existing.Spec.Template.Annotations["kubectl.kubernetes.io/restartedAt"] = fn.Status.LastBuiltAt.Time.Format(time.RFC3339)
```
Значение привязано к `fn.Status.LastBuiltAt` → меняется при каждой сборке → Kubernetes делает rolling restart → свежий образ гарантированно пул-ится.
**Правило проекта:** При использовании `:latest` tag всегда явно проставлять `restartedAt` annotation при обновлении кода.
**Версия:** operator `naeel/sless-operator:v0.1.11`
---
## 2026-03-10 — LLM-валидация кода при upload (pre-build security gate)
**Решение:** Интегрировать вызов облачного LLM в pipeline upload кода. LLM анализирует исходники пользователя **до** отправки в S3 и запуска kaniko. Если код подозрительный — upload отклоняется с HTTP 400 и причиной.
**Причина:**
1. LLM уже развёрнут (или скоро будет) в облаке nubes.ru — его нужно загрузить реальной работой.
2. Dogfooding: облачный провайдер использует собственный сервис ИИ в своём же продукте serverless.
3. Маркетинг: "ваш код проверяется ИИ перед деплоем" — реальная продающая фича.
4. Security: защита от криптомайнеров, ботнетов, DDoS-агентов, port scanners в пользовательских функциях.
**Точка интеграции:** `internal/api/handler/upload.go` — между распаковкой zip и упаковкой tar.gz.
**Pipeline с LLM:**
```
POST /upload (zip)
→ распаковка zip
→ извлечение текстовых файлов (.py, .js, .ts, .json, .sh, .sql ...)
→ POST к облачному LLM API с исходниками + системным промптом
→ safe=true → Dockerfile + tar.gz → S3 → CRD patch (обычный путь)
→ safe=false → HTTP 400 {"error": "code validation failed: <reason>"}
→ LLM error → warning в лог, upload продолжается (soft-fail)
```
**Режим работы: blocking + soft-fail**
- `safe=false` → upload отклоняется (HTTP 400), код не попадает в S3, сборка не начинается.
- LLM недоступен (timeout, 5xx) → upload **пропускается** (soft-fail), логируется warning.
Причина: недоступность LLM не должна ломать весь pipeline деплоя.
**Новый пакет:** `internal/validator/`
**Интерфейс:**
```go
// internal/validator/validator.go
type CodeValidator interface {
// Validate проверяет код функции перед сборкой.
// files — map[filename]content (текстовые файлы из zip).
// Возвращает (true, "") если код safe, (false, reason) если нет.
// При ошибке связи с LLM — возвращает (true, "") + логирует warning (soft-fail).
Validate(ctx context.Context, files map[string]string, runtime string) (safe bool, reason string, err error)
}
```
**LLM-реализация:**
```go
// internal/validator/llm.go
type LLMValidator struct {
endpoint string // URL облачного LLM API (OpenAI-compatible)
apiKey string // токен доступа
timeout time.Duration // default: 15s
log *slog.Logger
}
```
**Конфигурация (env vars):**
| Переменная | Default | Описание |
|------------|---------|----------|
| `LLM_ENABLED` | `false` | Включатель. false → NoopValidator (всегда safe) |
| `LLM_ENDPOINT` | — | URL LLM API, например `https://llm.nubes.ru/v1/chat/completions` |
| `LLM_API_KEY` | — | Bearer-токен для LLM API |
| `LLM_TIMEOUT` | `15s` | Максимальное время ожидания ответа |
`LLM_ENABLED=false` → оператор работает без LLM зависимости. По умолчанию выключено.
**Prompt-стратегия:**
Промпт НЕ хардкодится в Go — выносится в константу с возможностью override через ConfigMap.
```
You are a security reviewer for a serverless cloud platform.
Analyze the following {runtime} code deployed as a cloud function.
Check for:
1. Cryptocurrency mining (crypto hash algorithms, pool connections, stratum protocol)
2. DDoS/botnet behavior (mass outbound HTTP/UDP, connection floods)
3. Port scanning / network reconnaissance
4. Reverse shells, backdoors, C2 communication
5. Attempts to escape container (access host filesystem, /proc, /sys)
6. Obfuscated code designed to hide malicious intent
Files:
{files_content}
Respond ONLY with valid JSON, no other text:
{"safe": true} or {"safe": false, "reason": "brief explanation"}
```
**Что НЕ проверяем через LLM (не его задача):**
- Качество кода, стиль, best practices
- Уязвимости в зависимостях (это Trivy/npm audit, потом)
- Бизнес-логику пользователя
**Ограничения по размеру:**
- Суммарный размер текстовых файлов > 100KB → skip LLM (дорого, context window). Деплой проходит.
- Бинарные файлы (.pyc, .so, node_modules/) → не отправляются в LLM.
- Только расширения: `.py`, `.js`, `.ts`, `.json`, `.yaml`, `.yml`, `.txt`, `.sh`, `.sql`, `.go`.
**Встраивание в upload.go:**
```go
// После распаковки zip, до generateDockerfile
if h.Validator != nil {
files := extractTextFiles(zipData)
safe, reason, err := h.Validator.Validate(r.Context(), files, fn.Spec.Runtime)
if err != nil {
h.Log.Warn("llm validation error (soft-fail)", "err", err)
} else if !safe {
writeJSON(w, http.StatusBadRequest, errResp("code validation failed: "+reason))
return
}
}
```
**Terraform provider:** Получит `status 400: code validation failed: <reason>` — пользователь видит причину в `terraform apply` output.
**Файлы для реализации:**
1. `internal/validator/validator.go` — интерфейс CodeValidator + NoopValidator
2. `internal/validator/llm.go` — LLMValidator с HTTP client к OpenAI-compatible API
3. `internal/validator/extract.go` — extractTextFiles: zip → map[string]string
4. `internal/config/config.go` — добавить LLM_ENABLED, LLM_ENDPOINT, LLM_API_KEY, LLM_TIMEOUT
5. `internal/api/handler/handler.go` — добавить Validator поле
6. `internal/api/handler/upload.go` — вызов Validator между zip и tar.gz
7. `main.go` — wire: if LLM_ENABLED → LLMValidator, else → NoopValidator
**Компромиссы:**
- +5-15 секунд к каждому деплою (зависит от скорости LLM).
- 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`.
---
## 2026-03-11 — Namespace пользователя никогда не удаляется
**Решение:** User namespace (`sless-{hex16}`) **не удаляется** ни при каких обстоятельствах.
**Причина:**
- Namespace вычисляется из `JWT.sub` — неизменяемого идентификатора пользователя.
- Namespace = "home directory" пользователя в кластере: `terraform destroy` удаляет
функции/триггеры/джобы, но не сам контейнер для ресурсов.
- Удаление namespace уничтожило бы все CRD объекты пользователя.
- Повторный `terraform apply` (после destroy) нашёл бы свой ns живым — правильное поведение.
**Верификация (проверено):**
- В API нет маршрута `DELETE /v1/namespaces/{namespace}`.
- `handleDeletion` в `function_controller.go` удаляет: Deployment, Service, Ingress, kaniko Job.
- `handleTriggerDeletion` в `trigger_controller.go` удаляет: CronJob (в deployNS), Service, Ingress.
- Оба контроллера содержат явный комментарий: "Namespace sless-fn-{userNS} НЕ удаляется — он принадлежит пользователю".
- Тест: `kubectl get ns sless-cdd874dfa31ba6ca` — namespace жив через 93 минуты после `terraform destroy`.
**Оба namespace предохраняются:**
- `sless-{hex16}` — user namespace (хранит CRD объекты Function/Trigger/FunctionJob)
- `sless-fn-{hex16}` — deploy namespace (хранит Deployment/Service/Ingress/CronJob)
---
## 2026-03-11 — Builder SoC: context.go отделён от upload.go
**Проблема:** `generateDockerfile`, `runtimeBaseImage`, `zipToTarGz` жили в `handler/upload.go`.
Знание о runtime образах и структуре build context — детали **сборки**, не HTTP-хендлера.
Нарушение SoC: HTTP-файл знал о Docker, kaniko, tar.gz, zip-разборе.
**Решение:** Перенести в `internal/builder/context.go`, единственный публичный API:
```go
func PrepareContext(zipData []byte, runtime string) (*bytes.Buffer, error)
```
**Результат:**
- `upload.go`: ~200 LOC → ~60 LOC (только HTTP: принять zip, вызвать PrepareContext, сохранить в S3)
- `context.go`: всё знание о runtime образах, zip→tar, Dockerfile генерации
**Детали реализации:**
- `zipToTarGz` принимает `*zip.Reader` вместо `[]byte` — zip парсится один раз в `PrepareContext`
- `PrepareContext` сама сканирует zip-архив (requirements.txt, package.json) — хендлер не знает об этом
- `runtimeBaseImage` возвращает ошибку для неизвестного runtime — ранний fail до kaniko
**Тесты:** 4 теста в `internal/builder/context_test.go` (python+requirements, node без package.json, unsupported runtime, Dockerfile-first в tar).
---
## 2026-03-11 — Фильтрация hop-by-hop headers в /fn/ прокси
**Проблема:** `invoke.go` пробрасывал все заголовки ответа функции клиенту, включая hop-by-hop.
`Transfer-Encoding: chunked` особенно опасен: Go `http.ResponseWriter` не умеет его воспроизводить,
клиент получал некорректное тело ответа (или ошибку framing).
**Решение:** Фильтровать по RFC 2616 §13.5.1 перед записью в `w`:
```go
var hopByHopHeaders = map[string]bool{
"Connection": true, "Keep-Alive": true, "Proxy-Authenticate": true,
"Proxy-Authorization": true, "Te": true, "Trailers": true,
"Transfer-Encoding": true, "Upgrade": true,
}
// В цикле:
if hopByHopHeaders[k] { continue }
```
**Почему map[string]bool:** O(1) lookup, ключи в canonical form (`http.CanonicalHeaderKey`),
совпадает с форматом ключей в `http.Header` — нет нужды нормализовывать.
**Тесты:** 3 теста в `internal/api/handler/invoke_test.go`
(filtered from response, map contains all RFC2616, canonical key form).
---
## 2026-03-11 — JWKS insertion point stub в auth.go
**Контекст:** v1 auth — `validateJWT` проверяет структуру токена (sub, exp) без проверки подписи.
Это допустимо в trusted perimeter (оператор в k8s, доступен только изнутри).
**Решение:** Добавлена `verifySignature()` как закомментированная заготовка в `auth.go`.
**v2 план (когда nubes даст JWKS endpoint):**
1. `GET {NUBES_JWKS_URL}/.well-known/jwks.json`
2. Найти ключ по `kid` из JWT header
3. Проверить подпись RS256/ES256 через `github.com/lestrrat-go/jwx/v2`
4. Добавить вызов `verifySignature(token)` в `validateJWT` после проверки структуры.
**Зачем stub:** любой агент или разработчик видит точную строку для вставки. Нет риска забыть.
---
## 2026-03-11 — CronJob перенесён в deployNS
**Проблема:** CronJob для HTTP-триггеров создавался в `tr.Namespace` (user namespace: `sless-{hex16}`).
При применении NetworkPolicy (каждый namespace изолирован) — CronJob не мог бы дотянуться до API.
**Решение:** CronJob создаётся в `deployNS` = `"sless-fn-" + tr.Namespace`,
где живут Deployment/Service — NetworkPolicy там уже правильная.
**Затронутые места в trigger_controller.go:**
- `buildCronJob` — namespace в ObjectMeta
- `r.Client.Create` — нет изменений (namespace из объекта)
- `r.Client.Get` в reconcile — `deployNS` вместо ns
- `handleTriggerDeletion` — удаление CronJob из `deployNS`
**Дополнительно:** `curlimages/curl:latest``curlimages/curl:8.5.0` (pin версии).
---
## 2026-03-11 — Sort env vars в buildDeployment
**Проблема:** `fn.Spec.Env` — это `map[string]string`. Итерация по map в Go недетерминирована.
Каждый reconcile мог генерировать Pod spec с другим порядком env vars → лишние rollout'ы.
**Решение:**
```go
keys := make([]string, 0, len(fn.Spec.Env))
for k := range fn.Spec.Env { keys = append(keys, k) }
sort.Strings(keys)
for _, k := range keys { envVars = append(envVars, corev1.EnvVar{Name: k, Value: fn.Spec.Env[k]}) }
```
**Тесты:** 2 теста в `controllers/function_controller_unit_test.go`
(4 env vars → алфавитный порядок после SLESS_ENTRYPOINT; пустой Env → только SLESS_ENTRYPOINT).
+204
View File
@@ -0,0 +1,204 @@
# Архитектура системы
Последнее обновление: 2026-03-18 (v0.1.33 + funcs-service v0.1.3)
## Общее описание
Managed Serverless Functions Service для облачного провайдера nubes.ru.
Пользователь загружает код через Terraform, сервис его собирает (kaniko) и запускает
по HTTP-триггеру, расписанию (cron) или вручную через one-shot Job.
## Стек
| Компонент | Технология | Где запущен |
|-----------|-----------|-------------|
| Operator (API + Controllers) | Go (controller-runtime) | Kubernetes, namespace `sless` |
| funcs-service (глобальная консоль) | Go (net/http) | 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.
## Компонент: funcs-service
Глобальный HTTP сервис — **одна копия** на весь кластер, для всех пользователей.
```
User Browser / curl
└─► https://sless.kube5s.ru/funcs/<namespace>
└─► nginx Ingress (sless-funcs-ingress)
└─► sless-funcs-service:8090 (namespace sless)
└─► http://sless-operator.sless.svc.cluster.local:9090/v1/...
```
**Файлы:**
- `services/funcs/main.go` — логика
- `services/funcs/Dockerfile` — multi-stage Go → alpine
- `deployments/k8s/funcs-service.yaml` — Deployment + Service + Ingress
**Env vars сервиса:**
| Переменная | Значение |
|-----------|---------|
| `SLESS_OPERATOR_URL` | `http://sless-operator.sless.svc.cluster.local:9090` |
| `SLESS_EXTERNAL_URL` | `https://sless.kube5s.ru` |
| `SLESS_EXCLUDE` | `event-writer,event-monitor,event-cleaner` |
| `SLESS_SERVICE_TOKEN` | JWT токен (задаётся через `kubectl set env`, не в git) |
## Хранение кода функций в S3
```
Terraform source_dir (локально)
└─► zip → POST /upload → builder.PrepareContext()
├─► functions/{ns}/{name}/{ts}.zip ← ИСХОДНЫЙ КОД пользователя
└─► contexts/{ns}/{name}/{ts}.tar.gz ← BUILD CONTEXT для kaniko
└─► Function CRD: spec.s3Key = "contexts/..."
└─► контроллер → kaniko Job → Docker image
```
Для web-консоли: `GET /source` читает `functions/{ns}/{name}/{ts}.zip` напрямую.
## Изоляция пользователей — Namespace per user
Каждый пользователь облака получает **отдельный k8s namespace**.
```
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 **не создают** — это не их ответственность.
## Аутентификация
### Оператор (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 если нет)
## Схема вызова
```
Пользователь (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)
| | +-- context.go PrepareContext: zip+runtime → tar.gz+Dockerfile для kaniko
| |-- 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
Ingress: nginx, external IP 5.172.178.182
API endpoint: https://sless-api.kube5s.ru
## Версии в production
| Артефакт | Тег/Версия |
|---------|-----------|
| naeel/sless-operator | v0.1.22 |
| terra.k8c.ru/naeel/sless провайдер | v0.1.13 |
| naeel/sless-runtime-python3.11 | v0.1.1 |
| naeel/sless-runtime-nodejs20 | v0.1.2 |
+48 -1
View File
@@ -1,6 +1,53 @@
# Прогресс разработки # Прогресс разработки
Последнее обновление: 2026-03-18 12:00 (DNS инцидент устранён, POSTGRES E2E PASS) Последнее обновление: 2026-03-18 20:00 (funcs global service v0.1.3, ветка feat/web-console)
## 2026-03-18 — funcs: глобальный Go сервис + ветка web-console
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Python runtime str→text/plain | ✅ | `runtimes/python3.11/server.py`: если функция возвращает `str``Content-Type: text/plain`. Образ `naeel/sless-runtime-python3.11:v0.1.3` |
| 2 | funcs_list.py: plain text вывод | ✅ | Переписан с JSON на plain text с `─` разделителями, фильтр `SLESS_EXCLUDE` |
| 3 | Оператор v0.1.33 | ✅ | context.go: python runtime → v0.1.3; `internal/builder/context.go` обновлён |
| 4 | `funcs` как глобальный Go сервис | ✅ | `services/funcs/main.go` — standalone Go HTTP сервер. Деплоится ОДИН РАЗ в namespace `sless`, работает для ВСЕХ пользователей. Образ `naeel/sless-funcs-service:v0.1.3` |
| 5 | URL без токена | ✅ | `https://sless.kube5s.ru/funcs/<namespace>` — открывается в браузере без токена. `SLESS_SERVICE_TOKEN` задан через `kubectl set env` |
| 6 | Удалить funcs из terraform | ✅ | `examples/POSTGRES/resources.tf`: удалены `sless_function.funcs_list`, `sless_trigger.funcs_list_http`, `output "funcs_url"`, `local.user_namespace`. `terraform apply` — 2 destroyed |
| 7 | Ветка feat/web-console | ✅ | Создана от `feat/harbor-integration` на remote, запушена |
| 8 | Документация архитектуры хранения кода | ✅ | См. `doc/decisions/log.md` раздел "Хранение кода функций и web-консоль" |
| 9 | Коммиты | ✅ | `e8cd62e` (funcs Go service), `38bb494` (v0.1.3 — /funcs/namespace без токена) |
### Текущие URL функций
```
https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae # список функций (без токена)
https://sless.kube5s.ru/funcs?token=<jwt> # то же, через токен
https://sless.kube5s.ru/fn/sless-ffd1f598c169b0ae/pg-info
https://sless.kube5s.ru/fn/sless-ffd1f598c169b0ae/pg-table-reader
```
### Образы
| Образ | Версия | Что изменилось |
|-------|--------|----------------|
| `naeel/sless-operator` | v0.1.33 | created_at, last_built_at в API; python runtime v0.1.3 |
| `naeel/sless-runtime-python3.11` | v0.1.3 | str → text/plain в server.py |
| `naeel/sless-funcs-service` | v0.1.3 | /funcs/<namespace> без токена, /health probe |
---
## 2026-03-18 — Следующие шаги: web-консоль (ветка feat/web-console)
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Оператор: `GET /v1/namespaces/{ns}/functions/{name}/source` | ⏳ | Читает `functions/{ns}/{name}/{ts}.zip` из S3, возвращает JSON с файлами |
| 2 | Оператор: `PATCH /v1/namespaces/{ns}/triggers/{name}` | ⏳ | Принимает `{"enabled": true/false}`, обновляет Trigger CRD |
| 3 | `sless-funcs-service`: HTML страница | ⏳ | Если `Accept: text/html` → отдаёт HTML с JS; аккордеон файлов + кнопки ▶/■ |
| 4 | Bump оператора до v0.1.34 | ⏳ | После пп. 1-2 |
| 5 | Bump funcs-service до v0.2.0 | ⏳ | После п. 3 |
---
## 2026-03-18 12:00 — DNS инцидент устранён, POSTGRES E2E PASS
## 2026-03-18 — DNS инцидент: sless-api.kube5s.ru → мёртвый кластер ## 2026-03-18 — DNS инцидент: sless-api.kube5s.ru → мёртвый кластер