Files
sless/doc/decisions/log.md
T
Naeel b86ff3a62e feat(service): timeout_sec без дефолта; 0=нет таймаута; operator v0.1.48
- api/v1alpha1/service_types.go: убрать +kubebuilder:default=30
- invoke.go: TimeoutSec=0 → &http.Client{} (без таймаута)
- services.go: валидация timeout_sec < 0 || > 900 → HTTP 400
- service_resource.go: TF schema Optional (без Computed); 0 → Int64Null()
- deployments/k8s/operator.yaml: v0.1.47 → v0.1.48
- doc/: progress.md + api/design.md (модель Service) + decisions/log.md
- examples/POSTGRES/: bug_hunter.sh, chaos_marathon.sh, chaos_marathon.tf
2026-03-21 16:58:43 +03:00

62 KiB
Raw Blame History

Решения и обоснования


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 означало бы "оператор сам решит" что неверно
  • Диапазон 1900 — верхний предел защищает от бесконечных зависших запросов в 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. Это создавало два отдельных ресурса для одной задачи (запустить код один раз):

resource "sless_function" "f" { ... }      # build
resource "sless_job" "j" { function = sless_function.f.name ... }  # run

Решение

Сделать FunctionJobSpec самодостаточным: встроить Runtime/Entrypoint/Env/S3Key и запускать kaniko сборку непосредственно из FunctionJob-контроллера (новая фаза Building).

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 напрямую:

import "github.com/jackc/pgx/v5/pgxpool"

Версионирование образа

v0.1.0v0.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.gzfunctions/{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 токен → извлекает subSHA256[: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:

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 токенами может затронуть боевую среду

Как запускать:

# Сначала синхронизировать изменения:
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.jshttp.createServer, динамический require(HANDLER_PATH)
  • Зависимости через package.jsonnpm install --omit=dev (аналог requirements.txtpip 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.yamlEXTERNAL_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.goEnabled bool в TriggerSpec, //+kubebuilder:default=true
  • controllers/trigger_controller.go — патчит Deployment replicas=0/1 в зависимости от Enabled
  • internal/api/handler/triggers.goUpdateTrigger handler (PATCH), поле enabled в request/response
  • internal/api/router.goPATCH /v1/namespaces/{namespace}/triggers/{name}
  • internal/client/client.goTriggerUpdateRequest, 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.goRunID 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.goRunID 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 проставляем аннотацию:

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/

Интерфейс:

// 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-реализация:

// 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:

// После распаковки 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

Конфигурация:

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:

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:

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:latestcurlimages/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'ы.

Решение:

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:

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