827 lines
48 KiB
Markdown
827 lines
48 KiB
Markdown
# Решения и обоснования
|
||
|
||
## 2026-03-19 — Архитектура event-trigger (Вариант A: отдельный event-dispatcher)
|
||
|
||
### Контекст
|
||
|
||
До этого event-monitor/writer/cleaner работали как пользовательские sless-функции
|
||
в namespace юзера — это неправильно: они ходили в операторскую Postgres напрямую,
|
||
создавали таблицы без миграций, зависели от self-hosted rabbitmq.
|
||
Всё это удалено из кластера (audit 2026-03-19).
|
||
|
||
### Варианты которые рассматривались
|
||
|
||
**Вариант A: отдельный event-dispatcher сервис** ← ВЫБРАН
|
||
**Вариант B: dispatcher встроен горутиной в оператор**
|
||
**Вариант C: CronJob polling из очереди**
|
||
|
||
### Решение: Вариант A
|
||
|
||
**Почему не B:** AMQP-соединения внутри operator reconciler усложняют lifecycle
|
||
и тестирование. Падение AMQP затронет весь оператор.
|
||
|
||
**Почему не C:** polling — не realtime, не масштабируется, неловкий ACK.
|
||
|
||
**Почему A:** чистое разделение ответственности. Оператор управляет CRD,
|
||
dispatcher управляет AMQP. Независимые restart/deploy. Легко тестировать отдельно.
|
||
|
||
### Поток данных
|
||
|
||
```
|
||
Пользователь:
|
||
kubectl apply — Trigger{type:event, queue:"orders", functionRef:"my-func"}
|
||
|
||
sless-operator (trigger_controller.go):
|
||
reconcileEvent → валидирует что Function существует
|
||
→ устанавливает status.active = true
|
||
|
||
event-dispatcher (services/event-dispatcher/):
|
||
k8s informer наблюдает Trigger CRD по всем namespace
|
||
При type=event → amqp.Channel.Consume(spec.queue)
|
||
При сообщении → POST http://<fn-svc>.<fn-ns>.svc.cluster.local:8080/
|
||
→ 2xx → ack
|
||
→ не 2xx / timeout → nack (requeue)
|
||
При удалении Trigger → закрыть consumer
|
||
```
|
||
|
||
### Что меняется в коде
|
||
|
||
| Файл | Изменение |
|
||
|------|-----------|
|
||
| `api/v1alpha1/trigger_types.go` | +TriggerTypeEvent, +Queue в TriggerSpec |
|
||
| `controllers/trigger_controller.go` | +reconcileEvent (валидация + status) |
|
||
| `internal/config/config.go` | +RabbitMQURL |
|
||
| `services/event-dispatcher/` | новый Go-сервис (main + dispatcher + watcher) |
|
||
| `deployments/k8s/event-dispatcher.yaml` | Deployment + ServiceAccount + ClusterRole |
|
||
|
||
### Инфраструктура
|
||
|
||
RabbitMQ: managed через Nubes (Вариант A требует стабильного брокера).
|
||
- Управляется rabbitmq-operator в namespace `operators`
|
||
- namespace: `1dbfe9da-ce1c-4958-b359-d016a4b455c8`
|
||
- host: `rabbitmqk8s.1dbfe9da-ce1c-4958-b359-d016a4b455c8.svc.cluster.local`
|
||
- credentials: в `sless-operator-secret` (RABBITMQ_URL) — добавить при деплое
|
||
|
||
## 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, оператор v0.1.34 + funcs-service v0.2.0)
|
||
|
||
**Принцип:** минимум изменений в операторе, максимум логики в `sless-funcs-service`.
|
||
|
||
**Два новых эндпоинта в операторе:**
|
||
|
||
| Метод | Путь | Что делает |
|
||
|-------|------|-----------|
|
||
| GET | `/v1/namespaces/{ns}/functions/{name}/source` | Читает tar.gz из S3 (`Function.Spec.S3Key`) → JSON `[{name, content}]`, без Dockerfile |
|
||
| PATCH | `/v1/namespaces/{ns}/triggers/{name}` | `{"enabled": bool}` → обновляет Trigger CRD |
|
||
|
||
**`sless-funcs-service` — HTML режим:**
|
||
- Если запрос из браузера (`Accept: text/html`) → отдаёт HTML страницу
|
||
- Список функций — аккордеон; при раскрытии `fetch(/funcs/{ns}/source/{fn})` подгружает файлы
|
||
- Подсветка синтаксиса: `highlight.js` с CDN (не требует сборки)
|
||
- Кнопки ▶ Старт / ■ Стоп → `PATCH /funcs/{ns}/triggers/{name}` через fetch
|
||
- HTML шаблон `index.html` встроен в бинарник через `//go:embed index.html`
|
||
- `text/plain` ответ для curl/CLI остаётся без изменений (браузер шлёт `Accept: text/html`, curl — нет)
|
||
|
||
---
|
||
|
||
## 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).
|