Files

45 KiB
Raw Permalink Blame History

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

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)

Принцип: минимум изменений в операторе, максимум логики в sless-funcs-service.

Два новых эндпоинта в операторе:

Метод Путь Что делает
GET /v1/namespaces/{ns}/functions/{name}/source Читает zip из S3 → JSON [{name, content}]
PATCH /v1/namespaces/{ns}/triggers/{name} {"enabled": bool} → обновляет Trigger CRD

sless-funcs-service — HTML режим:

  • Если запрос из браузера (Accept: text/html) → отдаёт HTML страницу
  • Список функций — аккордеон; при раскрытии fetch(/source) подгружает файлы
  • Подсветка синтаксиса: highlight.js с CDN (не требует сборки)
  • Кнопки ▶ Старт / ■ Стоп → PATCH /triggers/{name} через fetch
  • text/plain ответ для curl/CLI остаётся без изменений

2026-03-18 — Смена sless API endpoint: sless-api.kube5s.ru → sless.kube5s.ru

Решение: Оператор sless доступен по https://sless.kube5s.ru (не sless-api.kube5s.ru). Все examples, deployments и ConfigMap обновлены.

Причина: При пересоздании кластера DNS-запись sless-api.kube5s.ru не была обновлена — она указывала на IP 5.172.178.182 (старый, мёртвый кластер). Ingress нового кластера закреплён на 185.247.187.147. Отдельная запись sless.kube5s.ru уже корректно указывала на 185.247.187.147.
TLS handshake timeout возникал потому что старый IP принимал TCP:443, но не завершал TLS (nginx жив, бэкенд мёртв). Go HTTP клиент ждал системный таймаут (~90s) и повторял бесконечно.

Изменения:

  • deployments/k8s/operator.yaml: EXTERNAL_URL, INGRESS_HOST, ingress host → sless.kube5s.ru
  • ConfigMap sless-operator-config в кластере: EXTERNAL_URL обновлён через kubectl patch
  • Ingress sless-operator в кластере: host + TLS secret → sless.kube5s.ru
  • Все examples/**/main.tf: endpoint = "https://sless.kube5s.ru"

Правило: При пересоздании кластера — первым делом проверять соответствие DNS → ingress IP.


2026-03-17 — Разделение prod/test endpoint'ов в examples/

Решение: Все examples/ ОБЯЗАНЫ использовать deck-api-test.ngcloud.ru для обоих провайдеров: nubes и sless (nubes_endpoint). Продовый deck-api.ngcloud.ru — только для реальных клиентов.

Причина: Смешивание prod и test endpoint'ов в одном terraform apply приводит к тому что ресурсы создаются в разных средах. sless_function/sless_job могут получать данные (PGHOST, credentials) из прода, а pod запускается в тест-кластере — и не может достучаться до хоста.

Правило для main.tf в examples:

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).