- Определена архитектура managed IoT service - Согласовано решение: RabbitMQ (MVP), потом Kafka; EMQX + CRD контроллер - Создан подробный план для Sonnet (doc/iot-mvp-plan.md) - Добавлено правило в copilot-instructions: лог мышления в doc/thinking/ по датам - Полный ход рассуждений в doc/thinking/2026-04-04.md Ключевые решения: - IoT код в iot/ (легко вынести потом) - CRD IoTDevice + контроллер (как все остальное в sless) - EMQX HTTP Auth Backend для динамической аутентификации устройств - Архитектура broker-agnostic (легко переключить на Kafka) - Terraform: расширяем текущий provider (sless_iot_device ресурс)
1258 lines
74 KiB
Markdown
1258 lines
74 KiB
Markdown
# Решения и обоснования
|
||
|
||
---
|
||
|
||
## 2026-04-01 — PG_TEST: lifecycle ignore_changes для vault_secrets — НЕ РАБОТАЕТ
|
||
|
||
### Попытка
|
||
|
||
Добавить в `nubes_postgres` блок `lifecycle { ignore_changes = [vault_secrets] }`.
|
||
|
||
### Результат
|
||
|
||
Terraform выводит предупреждение и игнорирует директиву:
|
||
> "Including this attribute in ignore_changes has no effect."
|
||
|
||
`vault_secrets` — `Computed`-only атрибут (выставляется только провайдером),
|
||
для таких атрибутов `ignore_changes` не применимо.
|
||
|
||
### Механизм проблемы
|
||
|
||
При `vault_secrets` "изменённом снаружи" Terraform обновляет `nubes_postgres` in-place,
|
||
но в плане выставляет `id = (known after apply)`. Дочерние ресурсы с `postgres_id`
|
||
(ссылка на `nubes_postgres.*.id`) теряют resolved value → форсированный replace.
|
||
|
||
### Текущий статус
|
||
|
||
Открытая проблема. `lifecycle ignore_changes` убран из конфигурации (не помогает).
|
||
Рабочий обходной путь на данный момент: запускать `apply` только на чистом state.
|
||
|
||
---
|
||
|
||
## 2026-04-01 — PG_TEST: depends_on chain вместо параллельного создания
|
||
|
||
### Решение
|
||
|
||
Все ресурсы `nubes_postgres_user` и `nubes_postgres_database` создаются строго
|
||
последовательно через явную цепочку `depends_on`:
|
||
|
||
```
|
||
nubes_postgres → pg_test_user → pg_test_db → extra_user1 → extra_user2 → extra_db1 → extra_db2
|
||
```
|
||
|
||
### Почему
|
||
|
||
Nubes API (deck-api-test.ngcloud.ru) не поддерживает параллельные операции создания
|
||
пользователей на одном PG-инстансе — возникает race condition на стороне Vault:
|
||
конкурентные записи в один Secret дают "Секрет не был создан" / "key doesn't exist".
|
||
|
||
Terraform по умолчанию выполняет независимые ресурсы параллельно (degree=10).
|
||
`depends_on` — единственный способ принудить последовательность без добавления
|
||
искусственных атрибутов-зависимостей.
|
||
|
||
---
|
||
|
||
## 2026-04-01 — PG_TEST: роль пользователя — только ddl_user
|
||
|
||
### Решение
|
||
|
||
В конфигурациях `examples/PG_TEST` используется только роль `ddl_user` для ресурсов
|
||
`nubes_postgres_user`. Роль `app_user` из конфигурации удалена.
|
||
|
||
### Почему
|
||
|
||
При создании `nubes_postgres_user` с `role = "app_user"` Nubes API (версия v5.0.51)
|
||
возвращает ошибку "Секрет для пользователя не был создан" после ~3 минут ожидания.
|
||
Воспроизводится стабильно. Роль `ddl_user` работает корректно.
|
||
|
||
Вывод: `app_user` либо не поддерживается в `nubes_postgres_user`, либо требует
|
||
другой конфигурации (не задокументированной). До выяснения — только `ddl_user`.
|
||
|
||
---
|
||
|
||
## 2026-03-30 — Переход на READ-ONLY подход в тестах (v2)
|
||
|
||
### Решение
|
||
Полностью переписан `vm_stress_test.sh`. Убраны все функции записи в файлы
|
||
(`write_tfvars`, `backup_tfvars`, `restore_tfvars`). Переопределения переменных
|
||
теперь через `-var` в terraform CLI. Добавлена проверка md5sum terraform.tfvars.
|
||
|
||
### Почему
|
||
Функция `write_tfvars()` в v1 уничтожила `terraform.tfvars`, потеряв JWT-токен
|
||
`api_token`. Пайплайн `grep | cut | xargs | sed` не смог корректно обработать
|
||
JWT строку длиной 1200+ символов. Восстановление потребовало ручного вмешательства.
|
||
|
||
### Ключевые принципы v2:
|
||
- Скрипт **НИКОГДА** не пишет в файлы (read-only)
|
||
- Все переопределения — через `terraform apply -var "key=value"`
|
||
- md5sum проверка после каждой фазы, аварийный стоп при изменении
|
||
- `terraform.tfvars` содержит секреты (api_token) → нельзя трогать
|
||
|
||
---
|
||
|
||
## 2026-03-30 — Автономный тестовый фреймворк для VM
|
||
|
||
### Решение
|
||
Внедрить bash-скрипт `vm_stress_test.sh` как основной инструмент для долгого, самовосстанавливающегося тестирования примера `examples/VM`.
|
||
|
||
### Почему
|
||
- Предыдущие ручные тесты подтвердили корректность логики, но для выявления редких race conditions и обеспечения преемственности между разными сессиями агентов нужен воспроизводимый сценарий.
|
||
- Скрипт инкапсулирует все "знания" о VM (IP, ключи, логика очистки `apt`), позволяя любому агенту запустить тест одной командой.
|
||
- Использование `timeout` на уровне команд `terraform` и `ssh` внутри скрипта предотвращает зависание автоматизации.
|
||
|
||
## 2026-03-29 — Матрица тестов VM example подтверждена прогоном
|
||
|
||
### Решение
|
||
|
||
Оставить текущую модель тестирования [examples/VM](examples/VM) как комбинацию из ручного cleanup, destroy/apply цикла, частичного отключения job-ресурсов и короткого stress loop.
|
||
|
||
### Почему
|
||
|
||
- Эта матрица проверяет и lifecycle VM, и идемпотентность job-ресурсов, и реакцию на изменение количества/порядка установок.
|
||
- Отдельный destroy/apply прогон подтвердил suspend/wake поведение без необходимости писать новый тестовый фреймворк.
|
||
- Stress loop из двух циклов дал полезную нагрузку без чрезмерного времени прогона.
|
||
|
||
## 2026-03-29 — Матрица тестов для VM example
|
||
|
||
### Решение
|
||
|
||
Для проверки поведения [examples/VM](examples/VM) использовать не один прогон, а набор сценариев:
|
||
|
||
1. обычный `apply` как базовый контроль;
|
||
2. удаление всего установленного ПО внутри ВМ перед `destroy`;
|
||
3. `destroy` с проверкой перехода ВМ в `suspend`;
|
||
4. повторный `apply` с проверкой wake-up и повторной установки;
|
||
5. изменение количества и порядка установок;
|
||
6. стресс-прогоны с несколькими повторениями.
|
||
|
||
### Почему так
|
||
|
||
- Один проход не показывает идемпотентность и не ловит проблемы порядка ресурсов.
|
||
- Сценарий с `destroy` проверяет, что инфраструктура не удаляет ВМ физически, а переводит её в `suspend`.
|
||
- Повторный `apply` после `suspend` проверяет восстановление состояния без ручного вмешательства.
|
||
- Перестановки и изменение количества установок нужны, чтобы проверить устойчивость к дрейфу и к разным графам зависимостей.
|
||
|
||
---
|
||
|
||
## 2026-03-21 — Оценка трудозатрат на проект
|
||
|
||
| Компонент | Оценка |
|
||
|-----------|--------|
|
||
| Go operator — CRD (Function, Trigger, Job), 3 контроллера, reconcile loops, self-healing | 80-100 ч |
|
||
| REST API — router, middleware, 8 handlers, namespace lifecycle | 40-50 ч |
|
||
| Terraform провайдер — provider, client, 4 ресурса | 40-60 ч |
|
||
| Builder — kaniko, S3 upload, context tar | 20-30 ч |
|
||
| Runtimes — Go/Node/Python базовые образы | 20-30 ч |
|
||
| Инфраструктура — k8s manifests, kustomize, Harbor, Postgres | 20-30 ч |
|
||
| Тесты — lifecycle (47) + survival (34), ~1900 строк bash | 40-60 ч |
|
||
| Документация — architecture, decisions, errors, API, handoffs | 20-30 ч |
|
||
|
||
**Итого: ~280-390 человеко-часов** (7-10 недель одного разработчика в нормальном темпе).
|
||
|
||
С AI-ассистентом в паре реальное живое время ~80-120 часов (30-50% от полного объёма).
|
||
|
||
---
|
||
|
||
## 2026-03-21 — timeout_sec для sless_service: 0 = нет лимита (не 30s по умолчанию)
|
||
|
||
### Контекст
|
||
|
||
В `api/v1alpha1/service_types.go` поле `TimeoutSec` имело `+kubebuilder:default=30`.
|
||
В `invoke.go` при `TimeoutSec == 0` был хардкод `35 * time.Second` как дефолтный клиент.
|
||
|
||
Пользователь хотел ввести таймаут как **опциональный** параметр: если не указан — длинные/бесконечные функции работают без ограничений. Дефолт 30s ломал это намерение.
|
||
|
||
### Решение
|
||
|
||
`TimeoutSec = 0` → «нет ограничения». Убраны все механизмы дефолтного таймаута:
|
||
1. `+kubebuilder:default=30` удалён из CRD — поле 0 по умолчанию в Go (zero value)
|
||
2. `invoke.go`: `if timeoutSec <= 0 { return &http.Client{} }` — Go Timeout=0 = нет дедлайна
|
||
3. `services.go`: валидация `< 0 || > 900` → 400 Bad Request (0 разрешён)
|
||
4. Terraform schema: убрано `Computed: true`; `svcToModel`: `0 → Int64Null()` (null в state)
|
||
|
||
### Почему именно так
|
||
|
||
- **0 = нет лимита** — стандарт в Go для http.Client.Timeout (явно задокументировано в stdlib)
|
||
- **null в Terraform** вместо 0 — чтобы пользователь видел "не задано", а не "0 секунд"
|
||
- **Computed убрано** — поле не знает своего значения пока пользователь не задал явно; Computed означало бы "оператор сам решит" что неверно
|
||
- **Диапазон 1–900** — верхний предел защищает от бесконечных зависших запросов в Production (15 минут достаточно для любой serverless задачи)
|
||
|
||
### Затронутые файлы
|
||
|
||
| Файл | Что изменено |
|
||
|------|-------------|
|
||
| `api/v1alpha1/service_types.go` | Убран `+kubebuilder:default=30`, добавлен комментарий |
|
||
| `internal/api/handler/invoke.go` | `invokeHTTPClient(0)` → `&http.Client{}` |
|
||
| `internal/api/handler/services.go` | Валидация в CreateService и UpdateService |
|
||
| `terraform/provider/internal/resources/service_resource.go` | Schema + svcToModel |
|
||
|
||
---
|
||
|
||
## 2026-03-21 — Объединить sless_function и sless_service в единый пользовательский листинг
|
||
|
||
### Контекст
|
||
|
||
`/funcs/{namespace}` (web-консоль) показывал только `sless_function` ресурсы (Kind=Function).
|
||
`sless_service` ресурсы (`pg-info`, `pg-table-reader`, `pg-table-writer`) были скрыты — пользователь не видел часть своих развёрнутых функций.
|
||
|
||
Первоначальный вопрос: почему `https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae` пуст?
|
||
Ответ: там были `sless_service`, а не `sless_function` — их не рендерили.
|
||
|
||
### Решение
|
||
|
||
Пользователю всё равно какой тип ресурса лежит под капотом — для него это просто «функция».
|
||
Объединить оба типа в один список с визуальным маркером типа:
|
||
- `sless_function` → бейдж `job`
|
||
- `sless_service` → бейдж `always-on`
|
||
|
||
Добавить поля `Kind` и `URL` в `fnResponse`. `fetchAndRender` теперь делает два запроса:
|
||
1. `GET /v1/namespaces/{ns}/functions` — sless_function (job-style)
|
||
2. `GET /v1/namespaces/{ns}/services` — sless_service (always-on Deployment)
|
||
|
||
Оба списка объединяются, фильтруются и сортируются единообразно.
|
||
|
||
### Почему НЕ делаем единый endpoint на операторе
|
||
Не усложняем оператор ради UI. Агрегацию делает funcs-service — он уже служит «фасадом» между браузером и оператором. Оператор остаётся строго CRUD.
|
||
|
||
### Имплементация
|
||
- `services/funcs/main.go`: `svcResponse`, объединение в `fetchAndRender`
|
||
- `services/funcs/index.html`: `badge-kind-service/function`, счётчик типов
|
||
- Коммит `683d728`, funcs-service `v0.2.1`
|
||
|
||
---
|
||
|
||
## 2026-03-21 — Добавить /services/{name}/source в оператор, не расширять proxySourceGet на API gateway
|
||
|
||
### Контекст
|
||
|
||
После объединения листинга возникла 404 при просмотре кода `sless_service` через web-консоль.
|
||
`proxySourceGet` передавал запрос на `/functions/{fn}/source`, но оператор маршрута `/services/{fn}/source` не имел.
|
||
|
||
### Варианты
|
||
1. В операторе: один универсальный `/resources/{fn}/source?type=service|function` — усложнит роутинг, нарушит REST-конвенцию.
|
||
2. В funcs-service: разветвлять URL по `kind` — но тогда funcs-service должен знать о внутренней топологии.
|
||
3. **Выбранный**: добавить отдельный `GET /v1/namespaces/{ns}/services/{name}/source` в оператор — симметрично с `/functions/{name}/source`. funcs-service передаёт `?kind` параметр.
|
||
|
||
### Почему так
|
||
- Симметричность `/functions/…/source` и `/services/…/source` — интуитивный REST.
|
||
- Никаких изменений в роутинге оператора — просто новый endpoint с той же логикой.
|
||
- `GetServiceSource` — буквально `GetSource` с `Service` CRD вместо `Function`. 30 строк кода.
|
||
- Коммит `50f2456`, оператор `v0.1.45`
|
||
|
||
---
|
||
|
||
## 2026-03-20 — Merge: убрать sless_function как обязательный prerequisite для sless_job
|
||
|
||
### Контекст
|
||
|
||
`sless_job` ранее требовал `FunctionRef` — имя существующего `sless_function` из которого брался `ImageRef`.
|
||
Это создавало два отдельных ресурса для одной задачи (запустить код один раз):
|
||
|
||
```hcl
|
||
resource "sless_function" "f" { ... } # build
|
||
resource "sless_job" "j" { function = sless_function.f.name ... } # run
|
||
```
|
||
|
||
### Решение
|
||
|
||
Сделать `FunctionJobSpec` самодостаточным: встроить `Runtime/Entrypoint/Env/S3Key` и запускать
|
||
kaniko сборку непосредственно из FunctionJob-контроллера (новая фаза `Building`).
|
||
|
||
```hcl
|
||
resource "sless_job" "j" {
|
||
runtime = "python3.11"
|
||
source_dir = "./code/fn"
|
||
...
|
||
}
|
||
```
|
||
|
||
### Почему НЕ удаляем sless_function
|
||
|
||
`sless_function` нужен для `sless_trigger` (type=cron/http) — они ссылаются на функцию.
|
||
Для триггеров образ должен жить вечно (не удаляться после запуска), и за ним следит Function CRD.
|
||
`sless_job` же — разовый запуск; после завершения Job удаляется, образ остаётся в registry.
|
||
|
||
### Изменения в State Machine FunctionJobReconciler
|
||
|
||
```
|
||
Было: Pending → (ждать Function.Ready) → Running → Succeeded/Failed
|
||
Стало: Pending → Building (kaniko) → Pending + ImageRef → Running → Succeeded/Failed
|
||
```
|
||
|
||
Фаза `Building` охраняется аннотацией `sless.kube5s.ru/build-job` — идемпотентна при рестарте.
|
||
|
||
---
|
||
|
||
## 2026-03-19 — Go runtime v0.1.1: внешние зависимости через go.mod/go.sum
|
||
|
||
### Контекст
|
||
|
||
Go runtime `naeel/sless-runtime-go1.23:v0.1.0` содержал `go.mod` только с `module sless/fn` и `go 1.23`.
|
||
Никаких `require` — пользовательский код мог использовать только stdlib.
|
||
|
||
При попытке добавить `pgxpool` в handler.go функция не собиралась (зависимость не найдена).
|
||
|
||
### Решение
|
||
|
||
Добавить `require github.com/jackc/pgx/v5 v5.7.2` в `runtimes/go1.23/go.mod`.
|
||
Сгенерировать `go.sum` через `go mod tidy` (stub `.go` файл с импортом нужен — иначе tidy удалит deps).
|
||
Обновить `Dockerfile` — добавить `COPY go.sum` + `RUN go mod download` **до** копирования пользовательского кода → зависимости кешируются в слое Docker, не скачиваются при каждой сборке функции.
|
||
|
||
### Почему pgx/v5, а не lib/pq
|
||
|
||
- `pgx/v5` — современный нативный PG-драйвер, `pgxpool` встроен, не нужен отдельный `database/sql`
|
||
- `lib/pq` — legacy, минимальный API, отсутствует connection pool
|
||
- `jackc/pgx/v5 v5.7.2` — последний стабильный тег на момент решения
|
||
|
||
### Что стало возможным
|
||
|
||
Любая Go функция в платформе может импортировать `pgxpool` и работать с PG напрямую:
|
||
```go
|
||
import "github.com/jackc/pgx/v5/pgxpool"
|
||
```
|
||
|
||
### Версионирование образа
|
||
|
||
`v0.1.0` → `v0.1.1` — изменение breaking: бинарник пересобирается с новыми deps.
|
||
Base image в `context.go` обновляется с `v0.1.0` на `v0.1.1`, оператор бампится.
|
||
|
||
---
|
||
|
||
## 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/.ssh/naeel_vm_id_ed25519" \
|
||
/home/naeel/remote_dev/sless/examples/<example>/ \
|
||
naeel@5.172.178.213:/home/naeel/terra/sless/examples/<example>/
|
||
|
||
# Затем запускать на remote:
|
||
ssh -i /home/naeel/.ssh/naeel_vm_id_ed25519 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).
|
||
|
||
---
|
||
|
||
## 2026-03-19 — pgx/v5 как PG-драйвер для Go функций (vs database/sql + lib/pq)
|
||
|
||
**Контекст:** Go runtime v0.1.1 — добавляем прямой доступ к PostgreSQL из функций.
|
||
Нужно выбрать: database/sql + lib/pq, или чистый pgx/v5?
|
||
|
||
**Решение:** Использовать `github.com/jackc/pgx/v5` напрямую, без обёртки database/sql.
|
||
|
||
**Причины:**
|
||
|
||
1. **pgxpool из коробки** — `pgxpool.New()` без дополнительных пакетов. lib/pq требует `sql.Open` + настройку пула через `db.SetMaxOpenConns` и т.д.
|
||
|
||
2. **Нативный протокол PostgreSQL** — pgx реализует wire protocol напрямую, без CGO.
|
||
lib/pq тоже pure Go, но pgx быстрее (~20% в бенчмарках) и активнее поддерживается.
|
||
|
||
3. **Контекст-нативность** — `pgxpool.Pool.Query(ctx, ...)` — context как первый аргумент везде.
|
||
В database/sql контекст пришёл только в Go 1.8 как `QueryContext` — неудобный retrofit.
|
||
|
||
4. **Сканирование строк** — `pgx.CollectRows`, `pgx.ForEachRow` — удобнее чем `rows.Scan`.
|
||
|
||
5. **Экосистема** — pgx — де-факто стандарт в Go+PG проектах (используется в pgx, pgvector, ent).
|
||
|
||
**Что добавлено в рантайм:**
|
||
```
|
||
github.com/jackc/pgx/v5 v5.7.2
|
||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||
github.com/jackc/pgservicefile v0.0.0-... // indirect
|
||
github.com/jackc/puddle/v2 v2.2.2 // indirect (connection pool)
|
||
golang.org/x/crypto v0.31.0 // indirect (scram auth)
|
||
golang.org/x/sync v0.10.0 // indirect
|
||
golang.org/x/text v0.21.0 // indirect
|
||
```
|
||
|
||
**go mod download** добавлен в Dockerfile до COPY server.go — слой с зависимостями кешируется отдельно.
|
||
Пересборка функции (только изменение handler.go) не перекачивает ~15MB зависимостей.
|
||
|
||
---
|
||
|
||
## 2026-03-19 — Динамический таймаут в invoke.go из Function.Spec.TimeoutSec
|
||
|
||
**Контекст:** invoke.go проксирует HTTP-запросы к подам функций. До этого — глобальный `http.Client{Timeout: 30s}`.
|
||
|
||
**Проблема:** 30s — константа времени написания кода. Функции с `timeout_sec=700` (stress-тесты, batch-задачи) падают с `context deadline exceeded` раньше чем успевают завершиться.
|
||
|
||
**Решение:** Перед каждым вызовом читать `Function.Spec.TimeoutSec` из k8s и создавать `http.Client` с таймаутом = `TimeoutSec + 5s`.
|
||
|
||
**Почему +5s буфер:**
|
||
- Нельзя ставить ровно `TimeoutSec` — есть сетевые задержки, TLS handshake, время на DNS резолв внутри кластера.
|
||
- 5s достаточно для любых сетевых задержек в локальном k8s кластере.
|
||
- Если функция реально завис на TimeoutSec — runtime сам должен прервать работу (это ответственность функции, не прокси).
|
||
|
||
**Почему не кешировать http.Client:**
|
||
- Каждый вызов может прийти к разной функции с разным TimeoutSec.
|
||
- http.Client создаётся дёшево — только структура с одним полем Timeout.
|
||
- Кеш потребовал бы sync.Map или mutex — лишняя сложность без измеримой пользы.
|
||
|
||
**Деградация при недоступности k8s:**
|
||
```go
|
||
if err := h.K8s.Get(r.Context(), client.ObjectKey{...}, fn); err == nil {
|
||
timeoutSec = fn.Spec.TimeoutSec
|
||
}
|
||
// если Get упал — timeoutSec=0 → invokeHTTPClient вернёт 30s (дефолт)
|
||
```
|
||
Это осознанный выбор: если мы не можем прочитать функцию — мы не знаем её таймаут,
|
||
используем разумный дефолт вместо возврата ошибки.
|
||
|
||
**Коммит:** `d7fda15`, оператор `v0.1.40`
|
||
|
||
---
|
||
|
||
## 2026-03-22 — Стратегия тестирования: G13 / G14 / G15
|
||
|
||
### Решение: разделить тесты на три группы
|
||
|
||
**Контекст:** После G12 failure test (45/45) нужно было покрыть оставшиеся сценарии.
|
||
|
||
**Варианты:**
|
||
1. Один большой тест-файл со всеми сценариями
|
||
2. Три отдельных группы по типу
|
||
|
||
**Выбрано:** Три отдельных файла:
|
||
- `operator_edge_cases_test.sh` (G13) — пользовательские ошибки и граничные случаи
|
||
- `operator_chaos_test.sh` (G14) — кластерный хаос (удаление ресурсов, kill pods)
|
||
- `operator_combined_test.sh` (G15) — комбинированный: хаос + пользовательские ошибки одновременно
|
||
|
||
**Почему:** Разные группы можно запускать независимо; G14 требует прав `kubectl` на деструктивные операции — отдельный файл делает намерение явным; время прогона ~25-50 мин каждый.
|
||
|
||
---
|
||
|
||
### Решение: `GET /fn/` разрешает все HTTP методы
|
||
|
||
**Контекст:** Тест G13D-3 ожидал 405 при GET на invoke-endpoint.
|
||
|
||
**Факт:** `router.go` строка 25: `r.PathPrefix("/fn/{namespace}/{name}").HandlerFunc(h.InvokeFunction)` — PathPrefix без `.Methods()` принимает ВСЕ методы.
|
||
|
||
**Решение:** Это архитектурный выбор: runtime-функция сама решает что делать с методом. Endpoint `/fn/` — это прокси, не контроллируемый API.
|
||
|
||
**Задокументировано:** В тесте G13D-3 как by-design поведение.
|
||
|
||
---
|
||
|
||
### Решение: G15D тест принимает 503 как transient
|
||
|
||
**Контекст:** При `kubectl delete pod` operator pod — API server временно недоступен.
|
||
|
||
**Факт:** Operator pod содержит и API-server и controller в одном бинарнике. Время перезапуска pod ~5-15s, в это время nginx/ingress отдаёт 503.
|
||
|
||
**Решение:** Тест документирует это как ожидаемое поведение (NOTE), передаёт как PASS. Если нужна HA — требуется multi-replica оператор (отдельное решение).
|
||
|
||
**Gap:** Для production нужен отдельный API-deployment с ≥2 replicas.
|
||
|