45 KiB
Решения и обоснования
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:
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. Локальный запуск запрещён.
Причина:
- Провайдер
terra.k8c.ru/naeel/slessкэширован только на удалённом сервере - Локальный terraform не имеет сетевого доступа к k8s кластеру и внутренним кластерным адресам (например PGHOST вида
*.svc.cluster.local) - Случайный локальный запуск с prod токенами может затронуть боевую среду
Как запускать:
# Сначала синхронизировать изменения:
rsync -av -e "ssh -i /home/naeel/remote_dev/common/id_ed25519.txt" \
/home/naeel/remote_dev/sless/examples/<example>/ \
naeel@5.172.178.213:/home/naeel/terra/sless/examples/<example>/
# Затем запускать на remote:
ssh -i /home/naeel/remote_dev/common/id_ed25519.txt naeel@5.172.178.213 \
'cd /home/naeel/terra/sless/examples/<example> && terraform apply -auto-approve -no-color'
2026-03-06 — Отдельная репа для сервиса
Решение: Serverless service в отдельной репе, не вместе с Terraform provider.
Причина: Разные зоны ответственности, разные релизы, потенциально разные команды.
2026-03-06 — Один бинарник для v1
Решение: Один Go бинарник вместо микросервисов.
Причина: Нагрузки изначально нет. Проще деплоить, проще отлаживать. Разделим при необходимости.
2026-03-06 — Аутентификация через облачный токен
Решение: Использовать Bearer token облака, без Keycloak.
Причина: Terraform provider уже работает с токенами облака. Keycloak — лишняя зависимость для v1.
2026-03-06 — S3 облачный, остальное в кубере
Решение: S3 (Ceph) использовать облачный (ceph.tst.nubes.ru), PostgreSQL/Redis — в кластере.
Причина: S3 имеет внешний доступ и уже готов. Для PostgreSQL/Redis сетевого связывания с облаком пока нет — настраивается через devops облака.
2026-03-06 — Текущий кластер для разработки
Решение: Использовать существующий k8s кластер (namespace sless), потом перенести на новый.
Причина: Новый кластер ещё не готов. Изоляция через namespace — безопасно для существующих сервисов.
2026-03-06 — RabbitMQ откладываем
Решение: В v1 только HTTP и Cron триггеры. RabbitMQ/event triggers — в v2.
Причина: Упрощение первой итерации.
2026-03-07 — DockerHub вместо внутреннего registry
Решение: Образы функций и runtime базовые образы публикуются на DockerHub (user naeel).
Причина: Namespace registry в кластере — это Apache NiFi Registry (NOT Docker). Отдельный Docker registry не поднят. DockerHub доступен и достаточен для разработки.
2026-03-07 — Terraform провайдер sless — отдельный модуль в той же репе
Решение: terraform/provider/ — независимый Go-модуль внутри репы sless.
Причина: Удобно держать рядом с кодом оператора во время разработки.
Важно: provider "sless" и provider "nubes" — это два отдельных независимых провайдера. Объединять их нельзя:
- разные зоны ответственности (
nubes— облачная инфраструктура,sless— serverless функции) - разные релизные циклы
- разные команды в будущем
Пользователь использует оба провайдера вместе в одном .tf файле, но это не означает что они должны быть одним бинарником.
2026-03-07 — WaitReady в Terraform провайдере при создании функции
Решение: После UploadCode провайдер ждёт phase=Ready (polling каждые 5 сек, таймаут 5 мин).
Причина: Kaniko-сборка занимает ~1 минуту. Без ожидания terraform apply завершился бы с phase=Building в state, что неверно отображало бы реальное состояние ресурса.
2026-03-07 — code_hash для детектирования изменений кода функции
Решение: Атрибут code_hash в sless_function — пользователь задаёт через filemd5("./handler.zip"). Изменение hash → провайдер перезагружает zip и запускает пересборку.
Причина: Terraform не отслеживает содержимое файлов автоматически. Это стандартный паттерн (аналогично aws_lambda_function.source_code_hash).
2026-03-07 — Scale-to-zero откладываем до v2
Решение: В v1 функции работают как Deployment с постоянно живым подом (always-on). Scale-to-zero — в v2 через KEDA HTTP Add-on.
Причина: Scale-to-zero меняет архитектуру контроллера и routing. Для MVP это несоразмерная сложность. Пользователь может управлять ресурсами вручную через replicas = 0/1/N (планируется в v1.1).
v2 план: Заменить Deployment на HTTPScaledObject (KEDA), минимальные реплики = 0. KEDA буферизует запросы во время cold start (~1-3 сек).
2026-03-07 — replicas как ручное управление масштабом (TODO v1.1)
Решение: Добавить поле replicas *int32 в FunctionSpec. Пользователь задаёт через Terraform: replicas = 0 (выключить), replicas = 1 (включить), replicas = N (масштабировать).
Причина: Без этого функция жрёт ресурсы 24/7 даже если не нужна. Это минимальный механизм контроля потребления до реализации scale-to-zero.
2026-03-07 — PostgreSQL опционален для базового Function Hosting
Решение: Postgres нужен только для логов вызовов (invocations). Для базового деплоя функций — не нужен. Оператор работает без него (просто не пишет логи).
Минимальные зависимости для production: k8s кластер + S3 + Docker registry + Ingress.
2026-03-07 — Версионированные теги для runtime образов (не :latest)
Решение: Runtime базовые образы (sless-runtime-python3.11, sless-runtime-nodejs20) и образ оператора (sless-operator) тегируются по схеме v<major>.<minor>.<patch>. :latest не используется.
Причина:
:latestприводит к непредсказуемому поведению: kaniko может взять старый кешированный образ, pod не перезапускается еслиimagePullPolicy: IfNotPresent.- Версионированные теги дают явный контроль: при изменении runtime нужно обновить тег в
upload.go→ это принудительно пересобирает все функции с новым базовым образом. - Аудит и откат: можно пинить конкретную версию runtime.
Соглашение:
- Runtime образы:
naeel/sless-runtime-{lang}:v{версия}(напримерv0.1.0) - Оператор:
naeel/sless-operator:v{версия} - При изменении runtime — инкрементировать минорную версию образа и обновить константу в
upload.go
2026-03-07 — nodejs20 как второй поддерживаемый runtime
Решение: Добавлен nodejs20 runtime (node:20-alpine base, server.js HTTP wrapper, exports.handle(event)).
Причина: Node.js — стандарт для serverless (AWS Lambda, Vercel). Покрывает JS/TypeScript аудиторию. Паттерн идентичен python3.11: runtime image → kaniko → Deployment.
Детали реализации:
runtimes/nodejs20/server.js—http.createServer, динамическийrequire(HANDLER_PATH)- Зависимости через
package.json→npm install --omit=dev(аналогrequirements.txt→pip install) entrypointв HCL игнорируется для Node.js (всегдаhandler.js+exports.handle) — TODO: поддержать произвольный entrypoint в v1.1
2026-03-07 — FunctionJob CRD: одноразовые запуски функций
Решение: Добавлен FunctionJob CRD для одноразового запуска функции с произвольным JSON-событием.
Причина: Нужны sync-вызовы без HTTP — для батч-обработки, миграций, крон-задач через Terraform.
Реализация:
api/v1alpha1/job_types.go— CRD:FunctionRef,EventJSON, phases: Pending/Running/Succeeded/Failedcontrollers/functionjob_controller.go— создаёт k8s Job, ждёт завершения, синхронизирует статусinternal/api/handler/jobs.go— REST: CreateJob/GetJob/DeleteJobterraform/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:8080internal/api/router.go—/fn/регистрируется до auth middleware, публично доступен;/v1/— по-прежнему с Bearer токеном (gorillaUse())internal/config/config.go— новое полеExternalURL(envEXTERNAL_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=truecontrollers/trigger_controller.go— патчит Deployment replicas=0/1 в зависимости от Enabledinternal/api/handler/triggers.go—UpdateTriggerhandler (PATCH), полеenabledв request/responseinternal/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=0controllers/functionjob_controller.go— если RunID==0 → устанавливает phase=Skipped, returninternal/api/handler/jobs.go— полеrun_idв jobRequest/jobResponseinternal/client/client.go—RunID int64в JobRequest/JobResponseterraform/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— configmapREGISTRY_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 и причиной.
Причина:
- LLM уже развёрнут (или скоро будет) в облаке nubes.ru — его нужно загрузить реальной работой.
- Dogfooding: облачный провайдер использует собственный сервис ИИ в своём же продукте serverless.
- Маркетинг: "ваш код проверяется ИИ перед деплоем" — реальная продающая фича.
- 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.
Файлы для реализации:
internal/validator/validator.go— интерфейс CodeValidator + NoopValidatorinternal/validator/llm.go— LLMValidator с HTTP client к OpenAI-compatible APIinternal/validator/extract.go— extractTextFiles: zip → map[string]stringinternal/config/config.go— добавить LLM_ENABLED, LLM_ENDPOINT, LLM_API_KEY, LLM_TIMEOUTinternal/api/handler/handler.go— добавить Validator полеinternal/api/handler/upload.go— вызов Validator между zip и tar.gzmain.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 → возвращает subclient.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 парсится один раз вPrepareContextPrepareContextсама сканирует 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):
GET {NUBES_JWKS_URL}/.well-known/jwks.json- Найти ключ по
kidиз JWT header - Проверить подпись RS256/ES256 через
github.com/lestrrat-go/jwx/v2 - Добавить вызов
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 в ObjectMetar.Client.Create— нет изменений (namespace из объекта)r.Client.Getв reconcile —deployNSвместо nshandleTriggerDeletion— удаление 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'ы.
Решение:
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).