Author SHA1 Message Date
cairnr01probe 2af5ebe1e3 chore: update ignore rules 2026-10-27 14:09:07 +03:00
Naeel 6447f17aca fix: invoke.go — forward Content-Length to proxied request (form POST fix)
Without ContentLength, Python BaseHTTPRequestHandler read 0 bytes from body.
operator v0.1.37, python runtime v0.1.4, pg-table-writer HTML form
2026-03-19 08:58:35 +03:00
Naeel f61bd0d23e docs: sync all documentation for agent handoff (v0.1.34 + funcs-service v0.2.0) 2026-03-18 20:19:48 +03:00
Naeel 24c32423f6 fix+docs: FunctionJob label bugfix, job ErrAlreadyExists, python str→text/plain, operator.yaml v0.1.33, progress.md
- controllers/functionjob_controller.go:
  - PodTemplate labels: functionjob=, function= (k8s 1.27+ удалил job-name=)
  - getJobPodOutput принимает labelSelector вместо jobName
  - захват stderr при Failed job; truncateForStatus() helper
- terraform/provider/internal/client/client.go: ErrJobAlreadyExists (409 Conflict)
- terraform/provider/internal/resources/job_resource.go: при конфликте создания — читаем существующий job
- runtimes/python3.11/server.py: str return → text/plain
- internal/builder/context.go: python runtime base image → v0.1.3
- deployments/k8s/operator.yaml: image → v0.1.33
- doc/progress.md: добавлены секции FunctionJob bugfix, str→text/plain, web-console v0.2.0
2026-03-18 17:41:44 +03:00
Naeel 15d6ecf0aa feat: web-console — HTML UI + source viewer + trigger toggle
- operator: GET /v1/namespaces/{ns}/functions/{name}/source
  reads build context tar.gz from S3, strips Dockerfile, returns JSON files
- funcs-service v0.2.0:
  - Accept: text/html → dark-themed HTML console with accordion cards
  - GET /funcs/{ns}/source/{fn} → proxy to operator (service token auth)
  - PATCH /funcs/{ns}/triggers/{name} → proxy enable/disable (only enabled field)
  - curl (no text/html Accept) → plain text as before (backward compat)
- highlight.js syntax highlighting per file extension
- operator v0.1.34, funcs-service v0.2.0
2026-03-18 17:24:43 +03:00
Naeel c46d7090cc docs: document all changes and web-console plan
- doc/progress.md: funcs global service v0.1.x, URLs, next steps
- doc/decisions/log.md: storage architecture, funcs service decision, web-console plan
- doc/api/design.md: current + planned endpoints (/source, PATCH trigger)
- doc/architecture/overview.md: funcs-service component, S3 storage diagram
2026-03-18 16:38:18 +03:00
Naeel 673888b5ec feat: funcs-service v0.1.3 — /funcs/<namespace> URL without token
- /funcs/<namespace>: user opens URL in browser, no auth needed
  service uses SLESS_SERVICE_TOKEN to query operator internally
- /funcs?token=<jwt>: token as query param (bookmarkable URL)
- /funcs: returns usage hint with both URL formats
- SLESS_SERVICE_TOKEN set via kubectl set env (not stored in repo)
2026-03-18 16:11:17 +03:00
Naeel cd18d33f4c feat: funcs as global Go service (sless-funcs-service:v0.1.1)
- services/funcs/main.go: standalone Go HTTP server
  extracts JWT sub -> SHA256[:8] -> namespace -> calls operator API
  returns plain text list, sorted active first, /health probe endpoint
- services/funcs/Dockerfile: multi-stage Go build -> alpine
- deployments/k8s/funcs-service.yaml: Deployment+Service+Ingress in sless ns
  ingress path /funcs -> sless-funcs-service:8090, reuses sless-operator-tls
- examples/POSTGRES/resources.tf: removed funcs_list function+trigger+output

Image: naeel/sless-funcs-service:v0.1.1
2026-03-18 15:58:08 +03:00
Naeel fa07b3230f feat: NodeJS pg-info function; funcs endpoint: filter + created_at/last_built_at; operator v0.1.32 2026-03-18 11:03:58 +03:00
Naeel 8b69adc882 feat: add funcs endpoint — list all functions with triggers for UI 2026-03-18 10:05:15 +03:00
Naeel 420b6ae650 feat: POSTGRES example — add pg-table-reader HTTP function 2026-03-18 09:23:13 +03:00
Naeel abf1dcd19f docs: 2026-03-18 DNS incident — sless-api→sless.kube5s.ru, POSTGRES E2E PASS 2026-03-18 09:07:47 +03:00
Naeel 3d6535f08d fix: migrate sless endpoint from sless-api.kube5s.ru to sless.kube5s.ru (new cluster ingress 185.247.187.147) 2026-03-18 08:48:50 +03:00
Naeel 3e8d07766e fix: remove all prod endpoint references from examples (test stand only) 2026-03-18 08:08:20 +03:00
Naeel b88df78ee5 docs: E2E results 2026-03-17 — all 4 examples PASS on test stand 2026-03-18 06:27:57 +03:00
cairnr01probe 44d72a9d21 0 2026-03-15 09:38:27 +04:00
Naeel e9d45884ef feat: demo managed functions with harbor-backed operator setup 2026-03-14 18:16:53 +03:00
Naeel b7bf75038c chore: ignore *.tfvars in examples, remove ai_hint_level from pg-list-python 2026-03-12 09:21:20 +03:00
Naeel c4606e5d05 chore: provider v0.1.18, update examples (harbor branch) 2026-03-12 09:19:17 +03:00
cairnr01probe ad77f5a021 chore: terraform.tfvars.change — инструкция по переименованию 2026-03-11 18:38:51 +04:00
cairnr01probe f674023116 chore: добавлены terraform.tfvars.change — шаблоны для токена (переименовать в terraform.tfvars) 2026-03-11 18:38:07 +04:00
cairnr01probe 19b245bdab fix: ModifyPlan — terraform plan теперь видит изменения в source_dir (provider v0.1.16) 2026-03-11 18:27:46 +04:00
cairnr01probe 27731ee5f7 security: убраны реальные токены из terraform.tfvars + инструкция в README 2026-03-11 17:41:34 +04:00
cairnr01probe 987a58fb03 docs: README для примера pg-list-python 2026-03-11 17:24:34 +04:00
cairnr01probe b1cad18531 docs: progress.md — harbor integration + UX fixes (v0.1.24–v0.1.28) 2026-03-11 17:23:32 +04:00
cairnr01probe 2d4849812f chore: python runtime v0.1.2 + operator v0.1.28
- context.go: python3.11 runtime → v0.1.2 (server.py с JSON для всех методов)
- operator.yaml: v0.1.28
2026-03-11 17:08:33 +04:00
cairnr01probe 2740154990 fix: build logs in status + JSON for all HTTP methods in python runtime
- function_controller.go: KubeClient + getBuildPodLogs → Function.Status.Message
  включает логи pip/kaniko при сбое сборки
- runtimes/python3.11/server.py: PUT/DELETE/PATCH/HEAD обрабатываются как вызовы функции;
  send_error переопределён → JSON вместо HTML 501
- operator.yaml: v0.1.27
- main.go: KubeClient передаётся в FunctionReconciler
2026-03-11 17:03:54 +04:00
cairnr01probe 09a955af5c fix: восстановлен requirements.txt после теста 2026-03-11 16:54:53 +04:00
cairnr01probe 2fec2dad18 test: невалидный requirements.txt 2026-03-11 16:53:01 +04:00
cairnr01probe d084cb5fed docs: переработан examples/README.md + комментарии function.tf 2026-03-11 16:48:01 +04:00
cairnr01probe 0d97da1faf fix: Decimal→float для JSON сериализации price 2026-03-11 16:39:56 +04:00
cairnr01probe 62bf905a6d feat: пример pg-list-python — список из PostgreSQL без джоба 2026-03-11 16:37:22 +04:00
cairnr01probe 863a2b1b06 chore: удалён отладочный push-sample (Harbor интеграция закончена) 2026-03-11 16:33:26 +04:00
cairnr01probe 5ed8f07c2e fix: go1.23 build context — COPY . /app/handler/ вместо COPY handler/ 2026-03-11 16:14:42 +04:00
cairnr01probe e381c00bec refactor: rename handler.go→greeting.go, buildGreeting() in hello-go example 2026-03-11 16:02:49 +04:00
cairnr01probe 1d022a6fa1 feat: go1.23 runtime support
- runtimes/go1.23/server.go: HTTP-wrapper + job-runner (SLESS_MODE=job)
- runtimes/go1.23/go.mod: module sless/fn (изолирует от корневого go.mod)
- runtimes/go1.23/Dockerfile: multi-stage build (golang:1.23-alpine → alpine:3.20)
- internal/builder/context.go: go1.23 в runtimeBaseImage + generateDockerfile
- controllers/functionjob_controller.go: go1.23 runner (nil cmd + SLESS_MODE=job env)
- api/v1alpha1/function_types.go: enum go1.21 → go1.23
- config/crd/bases/...: CRD обновлён
- terraform/provider: OneOf обновлён
- examples/hello-go: HTTP + job примеры на go1.23
- deployments/k8s/operator.yaml: v0.1.25
2026-03-11 15:40:47 +04:00
cairnr01probe 319a07c315 feat: harbor integration — EnsureProject + per-namespace image path
- internal/harbor/client.go: новый пакет, EnsureProject (GET+POST idempotent)
- config.go: добавлены HarborUser/HarborPass (из HARBOR_USER/HARBOR_PASS env)
- builder.go: Projecter интерфейс, harborClient поле, ImageRef: {host}/{ns}/{func}:{tag}
  EnsureProject вызывается перед каждым Build()
- function_controller.go: HarborClient поле, EnsureProject при создании k8s NS
- main.go: создание harbor.Client если HARBOR_USER+HARBOR_PASS заданы
- operator.yaml: REGISTRY_HOST=pearlharbor..., HARBOR_USER=admin, v0.1.24
- hack/create-registry-secret.sh: переписан для Harbor (HARBOR_USER/HARBOR_PASS)

Смена registry — только через REGISTRY_HOST в ConfigMap, больше нигде.
2026-03-11 14:58:04 +04:00
cairnr01probe 9a0dfc448c Ignore nested examples .git 2026-03-11 14:45:35 +04:00
cairnr01probe dcf2f68fd2 chore: staged files from prev session (gitignore, examples, harbor guide) 2026-03-11 14:36:32 +04:00
cairnr01probe 8b92fd125c docs: удалённая машина теперь полная — kubectl + kubeconfig скопирован 2026-03-11 12:58:39 +04:00
cairnr01probe d9210eba5e docs: stress test 4/4 PASS (v0.1.23), баги задокументированы 2026-03-11 12:41:04 +04:00
cairnr01probe 9cba8f07dc fix: operator v0.1.23 — BackoffLimit=2 в kaniko, пересоздание Failed функций; script: mod2 96→128 2026-03-11 12:26:06 +04:00
cairnr01probe bdff746ed2 fix: run_stress_test.sh — tf_output_json для объектных outputs, fix pipeline в assert_step 2026-03-11 11:35:27 +04:00
cairnr01probe 4bc1d681cd chore: gitignore logs dirs 2026-03-11 11:14:28 +04:00
cairnr01probe 67440f1edf test: run_stress_test.sh — полный стресс-тест всех примеров 2026-03-11 11:14:06 +04:00
cairnr01probe a3c1632cee test: E2E скрипт run_e2e_tests.sh + fix provider token/version в примерах 2026-03-11 10:49:36 +04:00
cairnr01probe edcf658cdd docs: update for v0.1.22 — Builder SoC, immediate fixes, unit tests 2026-03-11 09:42:12 +04:00
cairnr01probe c8c55b6046 refactor: Builder SoC + JWKS stub + unit tests
Builder SoC (builder/context.go):
- Moved generateDockerfile, runtimeBaseImage, zipToTarGz from handler/upload.go
  to internal/builder/context.go.
  Reason: knowledge about runtime images and build context structure is a
  build concern, not an HTTP handler concern.
- Added PrepareContext(zipData []byte, runtime string) (*bytes.Buffer, error) —
  single public entry point. Handler calls one function, gets ready buffer.
- zipToTarGz now accepts *zip.Reader instead of []byte to avoid double parsing.
- upload.go reduced from ~200 LOC to ~60 LOC (build logic gone).

auth.go — JWKS insertion point:
- Added verifySignature() stub with detailed comment explaining what v2
  implementation needs (JWKS endpoint, kid lookup, RS256/ES256 verify).
- Shows exactly where to add the call in validateJWT.

Unit tests (9 total, all pass):
- controllers: TestBuildDeployment_EnvVarsSorted, TestBuildDeployment_EmptyEnv
- handler: TestHopByHopHeaders_* (3 tests)
- builder: TestPrepareContext_PythonWithRequirements, _NodeNoPackageJSON,
           _UnsupportedRuntime, _DockerfileIsFirst
2026-03-11 09:34:34 +04:00
cairnr01probe a6b154a669 fix: immediate fixes from Opus review (v0.1.22)
- trigger: CronJob moved to deployNS (sless-fn-{userNS}), was tr.Namespace
  Reason: with NetworkPolicy default-deny, pod in user-ns can't reach
  Service in sless-fn-ns. Co-locating CronJob with Service guarantees
  connectivity regardless of NetworkPolicy configuration.
  handleTriggerDeletion updated consistently.

- trigger: pin curlimages/curl to 8.5.0 (was :latest)
  Reason: reproducibility, no unexpected behavior changes from image updates.

- function: sort env vars in buildDeployment (was non-deterministic map range)
  Reason: non-deterministic order caused k8s to detect container spec 'change'
  on every reconcile → unnecessary pod restarts. Sorted order is stable.

- function: cleanup kaniko Job in handleDeletion
  Reason: if Function deleted during Building phase, kaniko Job continued
  running, wasting CPU/memory and pushing an unused image.

- invoke: filter hop-by-hop headers in proxy response (RFC 2616 §13.5.1)
  Reason: Transfer-Encoding especially dangerous — forwarding it corrupts
  response body framing for the client.

- config: SLESS_API_TOKEN no longer required
  Reason: dead code — field loaded but never passed to any component.
  Auth uses validateJWT() middleware, not static token.

Namespace lifecycle: user namespaces preserved on destroy (not changed).
E2E: apply 4 resources + destroy clean. Operator v0.1.22 deployed.
2026-03-11 09:20:09 +04:00
cairnr01probe 7a85d7d439 docs: обновление документации 2026-03-11
- architecture/overview.md — актуальная архитектура: namespace-per-user,
  схема JWT->SHA256->namespace, структура кода, версии в production
- decisions/log.md — новые решения: два провайдера нельзя объединять,
  namespace-per-user, EnsureNamespace как отдельный endpoint (SoC),
  JWT validation вместо статического токена, валидация через nubes API,
  handler.go SoC рефакторинг
- progress.md — статус 2026-03-11 (all done), технический долг
- architecture/agent-handoff-2026-03-11.md — подробный handoff для Opus:
  полная архитектура, lifecycle контроллеров, примеры кода, ТЗ, вопросы
2026-03-11 08:47:50 +04:00
cairnr01probe 2362745aa2 refactor: SoC — EnsureNamespace в namespace.go, маршрут /ensure, client.EnsureNamespace, fix secrets в .gitignore
- handler.go: убраны бизнес-логика и k8s-типы (corev1/k8serrors/metav1)
  handler.go теперь только инфраструктура: Handler struct + helpers
- namespace.go: новый файл — EnsureNamespace хендлер живёт здесь
  SoC: создание namespace — отдельная ответственность, не смешивается с CRUD
- router.go: добавлен маршрут POST /v1/namespaces/{namespace}/ensure
- client.go: добавлен метод EnsureNamespace(ctx, ns) → POST /ensure
- provider.go: Configure() вызывает c.EnsureNamespace(ctx, namespace) после создания Client
  Namespace создаётся ОДИН РАЗ при инициализации провайдера
  Resource-хендлеры (Function, Trigger, Job) namespace не трогают
- .gitignore: добавлена директория secrets/ (токены, ключи)
- provider v0.1.13, operator v0.1.21

Operator: naeel/sless-operator:v0.1.21
Provider: terra.k8c.ru/naeel/sless v0.1.13
2026-03-11 08:37:33 +04:00
cairnr01probe dbb10b2658 feat: JWT auth in operator + hello-node example updated
- operator: auth middleware теперь валидирует JWT (sub+exp), не статический токен
- operator: ensureNamespace идемпотентен при race condition (IsAlreadyExists)
- operator: NewRouter убран параметр apiToken — больше не нужен
- examples/hello-node: prod.token + nubes_endpoint + версия провайдера 0.1.12
- протестировано: namespace sless-cdd874dfa31ba6ca создан автоматически
2026-03-11 07:51:06 +04:00
cairnr01probe 89f2f421ef feat: namespace-per-user via JWT sub SHA256 + ensureNamespace in operator
- operator: ensureNamespace() создаёт k8s namespace при первом Create-запросе
- operator: defaultNamespace константа вместо хардкода 'default'
- provider: SubFromJWT декодирует JWT payload, извлекает sub
- provider: NamespaceFromSub вычисляет sless-{sha256[:8]} из sub
- provider: PingNubesAPI валидирует токен запросом к nubes API
- provider: Configure вычисляет namespace и создаёт Client с ним
- provider: новый атрибут nubes_endpoint (опционально, env: NUBES_ENDPOINT)
2026-03-11 07:35:49 +04:00
cairnr01probe c937e52013 docs: clarify separate providers decision; fix invocations 501 stub 2026-03-11 07:24:34 +04:00
cairnr01probe 595e181f72 fix: stage0 quick fixes (operator v0.1.19)
- TriggerReconciler: RequeueAfter 15s когда Function не Ready
  (ранее зависал без повторного reconcile)
- FunctionJobReconciler: RequeueAfter 15s когда Function не Ready
- UpdateFunction: добавлена валидация runtime/entrypoint/memory_mb
  (ранее мог затереть spec нулями при частичном обновлении)
- CronJob: curlimages/curl:latest → curlimages/curl:8.5.0 (pin version)
- Config: удалён FunctionNamespacePrefix (мёртвое поле, нигде не использовалось)
- Invocations endpoint: возвращает 501 вместо пустого списка
  (SaveInvocation нигде не вызывается — честный ответ клиенту)
- Собран образ naeel/sless-operator:v0.1.19
2026-03-10 17:36:48 +04:00
cairnr01probe efce3564ef docs: добавлен анализ GPT-5.4 и Opus 4.6
- agent-handoff-2026-03-10.md — GPT-5.4 code review (lifecycle issues, invocation history gap)
- opus-pragmatic-review-2026-03-10.md — Opus прагматичный review для небольшого провайдера
- Opus: gVisor/LLM validation — overkill для MVP, фокус на быстрые фиксы + ResourceQuota/NetworkPolicy
- Обновлён progress.md с новыми документами
- .gitignore — добавлен test.token
2026-03-10 08:56:59 +04:00
cairnr01probe 0a483c01de docs: Claude Sonnet review of GPT-5.4 analysis + security roadmap
- Added sonnet-review-of-gpt-analysis.md
- GPT-5.4 правильно выявил lifecycle issues (event model, invocation history)
- Но пропустил security roadmap (gVisor, NetworkPolicy, LLM validation)
- Дополнен production security roadmap для следующего агента
- Updated progress.md — зафиксированы оба анализа
2026-03-10 08:36:46 +04:00
cairnr01probe 5955a62d99 docs: update progress.md, errors/log.md; add gitignore for dist/ and tfstate backups
- doc/progress.md: обновлена версия до operator v0.1.18 / provider v0.1.11,
  добавлен блок source_dir + fix destroy cleanup (2026-03-09),
  уточнены комментарии к контроллерам и trigger_resource
- doc/errors/log.md: добавлены две записи — source_dir/hashicorp/archive,
  destroy route cleanup bug (три причины + решения)
- .gitignore: добавлены examples/*/dist/ и terraform.tfstate.*.backup
- examples/: удалены dist/ zip и tfstate.*.backup из трекинга
2026-03-09 20:29:27 +04:00
cairnr01probe 6c11ebd1e1 docs: clarify sless_function description 2026-03-09 20:24:59 +04:00
cairnr01probe a914d60826 docs: remove informal tone from README 2026-03-09 20:24:23 +04:00
cairnr01probe b6bb1a01cd docs: add project description and resource types to examples README 2026-03-09 20:23:31 +04:00
cairnr01probe 2ea9cd268f fix: return 404 (not 502) when function Service is gone (DNS no such host)
After trigger/function destroy the Service is deleted, DNS lookup fails with
'no such host'. Previously InvokeFunction returned 502 which kept the test
script retrying for 120s. Now returns 404 — endpoint removed, test passes.

Operator: naeel/sless-operator:v0.1.18
2026-03-09 20:03:08 +04:00
cairnr01probe 58e9e6119b fix: restore token in provider blocks (dev-token-change-me is the real token) 2026-03-09 19:51:27 +04:00
cairnr01probe 50100040e8 chore: remove hardcoded token from examples, use SLESS_API_TOKEN env 2026-03-09 19:25:52 +04:00
cairnr01probe 1c063797f9 fix: destroy route cleanup bug — delete Service+Ingress on trigger/function deletion
- controllers/trigger_controller.go: handleTriggerDeletion теперь удаляет Service
  и Ingress из sless-fn-{ns} при удалении HTTP-триггера
- controllers/function_controller.go: handleDeletion теперь удаляет Service и Ingress
  (имена совпадают с fn.Name); добавлен импорт netv1
- terraform/provider trigger_resource.go: Delete ждёт пока GetTrigger вернёт 404
  (до 90с) — провайдер не возвращает успех раньше чем cleanup завершён
- examples: версия провайдера обновлена до ~> 0.1.11

Operator: naeel/sless-operator:v0.1.17
Provider: v0.1.11

Fixes DESTROY_ROUTE_CLEANUP_BUG.md
2026-03-09 19:23:28 +04:00
cairnr01probe 6aa01978ad chore: удалён пример pg-query 2026-03-09 17:46:52 +04:00
cairnr01probe 94ed02f631 feat: provider v0.1.10 — source_dir (zip внутри провайдера), убран archive провайдер из всех примеров 2026-03-09 17:45:45 +04:00
cairnr01probe addce19cfe docs: examples/README.md — порядок simple-first, без токена, без pg-query 2026-03-09 15:57:29 +04:00
cairnr01probe a309c72146 feat: provider v0.1.9 — namespace hardcoded in client, removed from provider schema and all examples 2026-03-09 15:27:55 +04:00
cairnr01probe d0ef085705 feat: provider v0.1.9 — namespace removed from resources, moved to provider block 2026-03-09 15:12:07 +04:00
cairnr01probe c4837b193e feat: operator v0.1.16 — job stdout -> status.Message (feature B)
- FunctionJobReconciler: added KubeClient field (kubernetes.Interface)
- getJobPodOutput(): reads pod logs via typed client after job succeeds
- main.go: inject kubernetes.NewForConfigOrDie into FunctionJobReconciler
- rbac.yaml: add pods/pods/log get/list/watch permissions
- examples/simple-python/: job->function chain demo (Python)
- examples/simple-node/: job->function chain demo (Node.js)

sless_job.X.message now contains the return value of the function
2026-03-09 14:50:06 +04:00
cairnr01probe d075a7c0f6 feat: outputs — статус init-джобов для пользователя без kubectl 2026-03-09 14:09:55 +04:00
cairnr01probe 5263843852 fix: operator v0.1.15 — обновлять Env при изменении функции
function_controller.go: при update deployment обновлять не только Image,
но и Env целиком. Без этого изменение entrypoint или env_vars не применялось
в уже существующем Deployment — поды продолжали стартовать со старым
SLESS_ENTRYPOINT и падали с FileNotFoundError.
2026-03-09 10:26:54 +04:00
cairnr01probe 662710713e refactor: уникальные имена функций-обработчиков
handle() → run_sql()    (sql_runner.py,  entrypoint: sql_runner.run_sql)
handle() → crud()       (notes_crud.py,  entrypoint: notes_crud.crud)
handle() → list_notes() (notes_list.py,  entrypoint: notes_list.list_notes)
2026-03-09 10:14:36 +04:00
cairnr01probe 9a48a6fcec refactor: notes-python — описательные имена файлов/ресурсов + комментарии
Python файлы:
- handler.py → sql_runner.py   (entrypoint: sql_runner.handle)
- handler.py → notes_crud.py   (entrypoint: notes_crud.handle)
- handler.py → notes_list.py   (entrypoint: notes_list.handle)

TF ресурсы переименованы:
- sless_function.notes         → sless_function.notes_crud
- sless_trigger.notes_http     → sless_trigger.notes_crud_http
- sless_job.create_table       → sless_job.notes_table_init
- sless_job.create_index       → sless_job.notes_index_init
- archive_file.notes           → archive_file.notes_crud_zip
- archive_file.sql_runner      → archive_file.sql_runner_zip
- archive_file.notes_list      → archive_file.notes_list_zip

Добавлены подробные комментарии во все .tf файлы
2026-03-09 10:10:43 +04:00
cairnr01probe 42387163c0 fix: provider v0.1.8 — wait for trigger URL after create
trigger_resource.go: after CreateTrigger, if URL is empty (HTTP trigger),
retry GetTrigger every 2s up to 30s until controller sets status.URL.
Without this, URL in terraform state was always empty after apply.
2026-03-09 09:58:37 +04:00
cairnr01probe b90b1bd7a6 feat: notes-python CRUD example + runtime path/query forwarding
- invoke.go: forward sub-path and query string to function pods
- server.js v0.1.2: add _path, _query, _method to event
- server.py v0.1.1: add _path, _query, _method to event
- upload.go: bump runtime versions (nodejs20:v0.1.2, python3.11:v0.1.1)
- examples/notes-python: CRUD notes via sub-path routing
  - sql-runner: generic SQL executor for DDL jobs
  - notes: CRUD router (/add, /update, /delete)
  - notes-list: SELECT all notes
  - init.tf: create TABLE + INDEX on apply
2026-03-09 09:51:56 +04:00
cairnr01probe 360f136989 refactor: pg-query — ресурсы вынесены в pg-query.tf, main.tf только провайдеры 2026-03-09 09:11:31 +04:00
cairnr01probe dabb290b7c refactor: pg-query — code/ исходники, dist/ zip, archive_file datasource 2026-03-09 09:07:57 +04:00
cairnr01probe 593bc3b2ad refactor: hello-node — zip-файлы в dist/, исходники остаются в code/ 2026-03-09 09:03:00 +04:00
cairnr01probe 00a350e24b fix: pg-query — filesha256(handler.py) вместо filemd5(handler.zip), версия провайдера 0.1.7 2026-03-09 08:56:19 +04:00
cairnr01probe 489da2c0bd fix: API validation + Terraform plan-time validators
API (operator v0.1.13):
- functions.go: добавлена валидация entrypoint (не пустой) и
  memory_mb (1-4096). Фиксирует БАГ-1/2/4 из негативных тестов.
- triggers.go: добавлена валидация type (только 'http'/'cron').
  Фиксирует БАГ-3 (неверное сообщение об ошибке).

Провайдер (v0.1.7):
- Добавлен пакет terraform-plugin-framework-validators v0.19.0
- function_resource: runtime OneOf, memory_mb 1-4096, timeout_sec 1-900
- trigger_resource: type OneOf(http, cron)
- job_resource: run_id AtLeast(0)
- examples/main.tf: обновлена версия до ~> 0.1.7

doc/errors/log.md: задокументированы исправления и результаты повторных тестов
2026-03-09 08:52:13 +04:00
cairnr01probe a77ac02e1e fix: откат 409-fallback из клиента провайдера (provider v0.1.6 не опубликован с изменениями)
- terraform/provider/internal/client/client.go: откат 409 fallback для
  CreateFunction, CreateTrigger, CreateJob — import не нужен в данном провайдере,
  409 должен быть ошибкой
- examples/hello-node/main.tf: версия провайдера ~> 0.1.6 (опубликована
  без изменений клиента — только исходная версия кода)
- examples/hello-node/job.tf: run_id=9, numbers=[100,200,300]

Тесты пройдены: plan(no-changes), state-rm+apply, code update, run_id++, destroy+apply
2026-03-09 08:17:17 +04:00
cairnr01probe b1f98021e9 fix: уникальный image tag per build вместо :latest (operator v0.1.12)
- internal/builder/builder.go: ImageRef() теперь использует sha256(s3Key)[:12]
  как тег образа вместо :latest
  Пример: naeel/sless-default-hello-http:d431de61e2c9
- controllers/function_controller.go: убран хак с restartedAt аннотацией —
  больше не нужен, новый тег = kubelet всегда пул-ит новый образ
- deployments/k8s/operator.yaml: обновлён до v0.1.12
- doc/progress.md: версия обновлена

Преимущества уникального тега:
- imagePullPolicy: IfNotPresent работает корректно
- Rollback к предыдущей версии возможен (теги не перезаписываются)
- Нет race condition при параллельных сборках
2026-03-08 21:05:35 +04:00
cairnr01probe fa3cd9f691 fix: rollout restart после kaniko rebuild (operator v0.1.11)
- controllers/function_controller.go: в ensureDeployment добавить аннотацию
  kubectl.kubernetes.io/restartedAt = fn.Status.LastBuiltAt при обновлении
  существующего Deployment — принудительный rolling restart после каждой сборки
- deployments/k8s/operator.yaml: образ обновлён до v0.1.11
- doc/errors/log.md: задокументирована причина и решение
- doc/decisions/log.md: добавлено правило restartedAt + :latest
- doc/progress.md: версия обновлена до v0.1.11

Причина бага: imagePullPolicy=IfNotPresent + :latest — kubelet кешировал
старый образ, pod не рестартовал после успешного kaniko build.
2026-03-08 20:55:47 +04:00
cairnr01probe 2180c68e01 docs: обновление doc/ по итогам сессии 2026-03-08 2026-03-08 20:36:16 +04:00
cairnr01probe 67914155b9 fix: убрать UseStateForUnknown с image_ref, provider v0.1.5 2026-03-08 20:34:00 +04:00
cairnr01probe 953889db5a config: REGISTRY_HOST переключён с Harbor на DockerHub (naeel) 2026-03-08 20:18:59 +04:00
cairnr01probe a58d3b9af5 fix: FunctionJob зависал в Running — добавлен RequeueAfter polling 2026-03-08 12:13:15 +04:00
cairnr01probe df98991b3a fix: imagePullSecrets, SLESS_ENTRYPOINT, registry secret propagation
- function_controller: добавить RegistrySecret + OperatorNamespace, копировать
  sless-registry-auth в sless-fn-<ns>, выставлять imagePullSecrets в Deployment,
  также обновлять imagePullSecrets при reconcile
- functionjob_controller: fnEnvVars включает SLESS_ENTRYPOINT, runner читает его
- server.js + server.py: читать SLESS_ENTRYPOINT вместо hardcoded handler.js/py
- rbac.yaml: добавить права на secrets
- operator.yaml: v0.1.8
- main.go: передать RegistrySecret + OperatorNamespace в FunctionReconciler
2026-03-08 11:15:48 +04:00
cairnr01probe a231e0c8c4 feat: trigger.enabled + job.run_id lifecycle control (operator v0.1.6, provider v0.1.4)
- TriggerSpec.Enabled bool (default=true): enabled=false масштабирует Deployment до 0
- FunctionJobSpec.RunID int64 (default=0): run_id=0 = skip, >0 = run
- API: PATCH /v1/namespaces/{ns}/triggers/{name} (UpdateTrigger)
- Provider: enabled attribute (Optional, Computed, in-place update)
- Provider: run_id attribute (Optional, Computed, default=0, RequiresReplace)
- operator image: naeel/sless-operator:v0.1.6
- provider: terra.k8c.ru/naeel/sless v0.1.4
2026-03-08 10:10:32 +04:00
cairnr01probe 19fe8e41e5 ops: switch registry from DockerHub to Harbor (pearlharbor.registryk8s.services.ngcloud.ru/sless) 2026-03-08 09:28:18 +04:00
cairnr01probe aad33b79a4 examples/hello-node: two different functions (http greeting, job sum) 2026-03-08 09:22:38 +04:00
cairnr01probe 87f28d8b19 examples/hello-node: move handler to code/, split http.tf and job.tf 2026-03-08 09:14:34 +04:00
cairnr01probe a9ff7ace7f examples: switch hello-node to sless_job (one-shot run) 2026-03-07 23:05:09 +04:00
cairnr01probe 11c6faf4e9 examples: auto-zip via hashicorp/archive, remove manual zip step 2026-03-07 18:46:23 +04:00
cairnr01probe 221c46e7f7 doc: обновлена документация — FunctionJob, proxy /fn/, operator v0.1.5, provider v0.1.3 2026-03-07 18:40:36 +04:00
cairnr01probe 65ead2554c feat: proxy /fn/{namespace}/{name} — обход wildcard DNS
Проблема: wildcard DNS *.fn.kube5s.ru недоступен.
Решение: прокси через sless-api.kube5s.ru/fn/{ns}/{name}.

- handler/invoke.go: прокси к Service функции внутри кластера
- router.go: /fn/ без auth токена, /v1/ с auth (gorilla Use())
- config.go: поле ExternalURL (EXTERNAL_URL env)
- trigger_controller.go: если ExternalURL задан — URL = ExternalURL/fn/{ns}/{fn}
  иначе fallback: Ingress + поддомен (прежнее поведение)
- operator.yaml: EXTERNAL_URL=https://sless-api.kube5s.ru, image v0.1.5

Оператор v0.1.5 задеплоен.
E2E: curl https://sless-api.kube5s.ru/fn/default/hello-node → {"message":"Hello, Naeel! (nodejs20)"}
2026-03-07 18:36:03 +04:00
cairnr01probe 54b7f4404e feat: configurable timeouts for sless_function and sless_job
- sless_function: build_timeout_sec (optional, default 300s = 5 min)
- sless_job: wait_timeout_sec (optional, default 600s = 10 min)
- provider v0.1.3 published to terra.k8c.ru
2026-03-07 17:49:07 +04:00
cairnr01probe 2fec209880 feat: sless_job terraform resource + jobs REST API
Operator (v0.1.4):
- internal/api/handler/jobs.go: CreateJob/GetJob/DeleteJob handlers
- internal/api/router.go: POST/GET/DELETE /v1/namespaces/{ns}/jobs/{name}

Terraform provider (v0.1.2):
- client.go: JobRequest/JobResponse + CreateJob/GetJob/DeleteJob/WaitJobDone
- resources/job_resource.go: sless_job resource (Create blocks until Succeeded/Failed)
- provider.go: register NewJobResource

Deploy:
- operator:v0.1.4 deployed and running in cluster
- provider v0.1.2 published to terra.k8c.ru
2026-03-07 17:43:26 +04:00
cairnr01probe 4bbb3cb4b2 feat: add FunctionJob CRD for one-shot function runs
- api/v1alpha1/job_types.go: new CRD FunctionJob (Pending/Running/Succeeded/Failed)
- controllers/functionjob_controller.go: reconciler creates k8s Job from FunctionRef + EventJSON
- zz_generated.deepcopy.go: DeepCopy methods for FunctionJob types
- config/crd/bases: generated CRD YAML, applied to cluster
- main.go: register FunctionJobReconciler
- rbac.yaml: add functionjobs permissions
- operator.yaml: v0.1.2 -> v0.1.3
- operator:v0.1.3 deployed and running in cluster
2026-03-07 17:20:21 +04:00
cairnr01probe 7fc9bef96e doc: document operator deploy, nodejs20 runtime, Dockerfile fixes, go.mod fix 2026-03-07 17:02:28 +04:00
cairnr01probe ee71eccf0c feat: add nodejs20 runtime
- runtimes/nodejs20/server.js: HTTP wrapper, exports.handle(event)
- runtimes/nodejs20/Dockerfile: node:20-alpine base image
- naeel/sless-runtime-nodejs20:v0.1.0 pushed to DockerHub
- upload.go: nodejs20 in runtimeBaseImage(), package.json → npm install
- upload.go: python3.11 now uses v0.1.0 tag (no more latest)
- operator v0.1.2 deployed in cluster
- E2E: hello-node-default.fn.kube5s.ru → {"message":"Hello, Naeel! (nodejs20)"}
2026-03-07 17:00:29 +04:00
cairnr01probe 6bcdb31f25 chore: switch pg-query example to remote endpoint sless-api.kube5s.ru 2026-03-07 16:50:05 +04:00
cairnr01probe b5fe310e33 chore: go mod tidy — fix direct/indirect annotations 2026-03-07 16:31:54 +04:00
cairnr01probe e8fa9e1723 feat: deploy operator in-cluster v0.1.1
- Dockerfile: fix golang 1.23→1.25, add internal/ + migrations/ to image
- deployments/k8s/operator.yaml: ConfigMap + Secret + Deployment + Service + Ingress
- Ingress: sless-api.kube5s.ru, TLS via cert-manager letsencrypt-prod
- naeel/sless-operator:v0.1.1 — running in namespace sless
- E2E: https://sless-api.kube5s.ru → HTTP/2 200
2026-03-07 16:30:14 +04:00
cairnr01probe 7deea18848 fix: multiple fixes + pg-query e2e passed
upload.go:
- Update → Patch (MergeFrom) to avoid resourceVersion conflict when operator
  modifies Function between Get() and Update()

terraform provider v0.1.1:
- trigger_resource.go: trToModel returns StringNull() for empty schedule
  (fixes 'provider produced inconsistent result' for http triggers)
- main.go: bump version to 0.1.1

examples/pg-query:
- handler.py: fix column name started_at → created_at (matches migrations/001)
- main.tf: pin provider to ~> 0.1.1

.gitignore: add terraform state, lock, .terraform/, handler.zip

doc/errors/log.md: documented all 5 errors from this session:
- resourceVersion conflict → use Patch
- terraform inconsistent result for schedule → StringNull
- terraform import not implemented → delete+recreate workaround
- wrong column name → check migrations before writing handlers
- Deployment not restarting after image rebuild → rollout restart / TODO: restartedAt annotation

doc/progress.md: terraform apply e2e ✅
2026-03-07 10:52:29 +04:00
cairnr01probe e2c366a406 feat: add pg-query example + upload.go supports requirements.txt
- examples/pg-query/handler.py: Python function querying PostgreSQL invocations table
- examples/pg-query/requirements.txt: psycopg2-binary==2.9.9
- examples/pg-query/main.tf: terraform config for sless_function + sless_trigger
- internal/api/handler/upload.go: generateDockerfile() now accepts hasRequirements bool
  - scans zip for requirements.txt at upload time
  - adds RUN pip install --no-cache-dir to Dockerfile when requirements.txt present
- doc/progress.md: updated status for pg-query e2e task
2026-03-07 10:30:50 +04:00
cairnr01probe 5f8dd6fa25 docs: обновить всю документацию по результатам сессии 2026-03-07
- infrastructure/overview.md: исправить S3 URL, Container Registry (DockerHub),
  namespace sless, Terraform Registry terra.k8c.ru
- errors/log.md: добавить 3 ошибки — kaniko S3 scheme, бесконечный цикл сборки,
  docker credStore desktop.exe
- decisions/log.md: добавить решения — DockerHub, terraform провайдер в той же
  репе, WaitReady паттерн, code_hash для версионирования кода
- api/design.md: обновить базовый URL, добавить upload endpoint с описанием
  процесса, исправить runtime список
2026-03-07 10:26:13 +04:00
cairnr01probe 3d834e9b42 feat: Terraform провайдер sless v0.1.0 + обновление doc/progress.md
Независимый Go-модуль terraform/provider/ для управления функциями через REST API.

Ресурсы:
- sless_function: CRUD + upload zip + WaitReady (kaniko build 5 мин)
- sless_trigger: CRUD (http/cron)

Компоненты:
- internal/client/client.go   — HTTP-клиент, готов к переносу в nubes
- internal/provider/provider.go — паттерн идентичен nubes NubesProvider
- internal/resources/           — function_resource.go, trigger_resource.go
- hack/build-and-publish.sh    — GPG-подпись + mc upload в S3

Опубликовано: terra.k8c.ru/naeel/sless v0.1.0
Проверено: terraform init успешен
2026-03-07 10:23:52 +04:00
cairnr01probe 290d6514e7 fix: бесконечный цикл сборки — idempotency guard через last-built-s3key аннотацию
Проблема: upload handler сбрасывал phase в Pending ПОСЛЕ того как
контроллер уже выставил Building → бесконечный цикл (94 job'а).

Решение:
- controllers/function_controller.go: startBuild сначала ставит аннотацию
  last-built-s3key = spec.S3Key (idempotency guard), потом status.
  Reconcile стартует сборку только если spec.S3Key != last-built-s3key.
- handler/upload.go: убран Status().Update() — контроллер сам управляет фазой.
- builder.go: IsAlreadyExists при создании job не ошибка (parallel reconcile).
2026-03-07 09:50:24 +04:00
cairnr01probe 15db7c972d fix: kaniko S3 endpoint — добавить https:// схему и S3_FORCE_PATH_STYLE=true
Kaniko AWS SDK требует полный URL с протоколом.
Ceph не поддерживает virtual-hosted style — нужен path style.
2026-03-07 09:39:53 +04:00
cairnr01probe c13edd7ee1 feat: upload endpoint POST /v1/namespaces/{ns}/functions/{name}/upload
- s3/client.go: Bucket() accessor + UploadContext() для tar.gz build context
- handler/upload.go: принимает zip, генерирует Dockerfile (FROM naeel/sless-runtime-{runtime}),
  перепаковывает в tar.gz, загружает в S3, обновляет Function CRD → kaniko запускается
- router.go: маршрут POST .../upload зарегистрирован
2026-03-07 09:36:32 +04:00
cairnr01probe 5cc438d9e6 feat: python3.11 base runtime image + DockerHub registry support
- runtimes/python3.11/: Dockerfile + server.py (HTTP wrapper на :8080)
- builder.go: RegistrySecret field, volume mount docker-creds для kaniko, убран --skip-tls-verify
- config.go: добавлен RegistrySecret (env REGISTRY_SECRET, default sless-registry-auth)
- main.go: передаём RegistrySecret в builder.Config
- hack/create-registry-secret.sh: создаёт k8s Secret из DockerHub токена

Образ наружу: naeel/sless-runtime-python3.11:latest (DockerHub)
K8s secret: sless-registry-auth в namespace sless
2026-03-07 09:32:59 +04:00
cairnr01probe 37ff9dd5d2 docs: обновить progress.md по фактическому состоянию
- отражён реальный статус всех компонентов кода
- отражено состояние кластера (CRD, RBAC, postgres)
- добавлен список что ещё не сделано
- добавлены deployments/k8s/rbac.yaml, postgres.yaml
2026-03-07 08:57:54 +04:00
cairnr01probe 78e4f6bca3 feat: REST API (gorilla/mux + slog) + trigger controller + main.go wiring
- controllers/trigger_controller.go: полная реализация, HTTP->Ingress+Service, Cron->CronJob
- internal/config: добавлены IngressHost, APIToken
- internal/api/router.go: gorilla/mux роутер /v1/namespaces/{ns}/...
- internal/api/handler/: functions, triggers, invocations, base handler с slog
- internal/api/middleware/: auth (Bearer token) + logging (slog)
- main.go: запуск operator + HTTP сервера параллельно
- go.sum: добавлен gorilla/mux v1.8.1
2026-03-07 08:46:10 +04:00
cairnr01probe df5de1fc4c chore: ignore .s3cfg* files 2026-03-07 08:28:33 +04:00
cairnr01probe 8854750445 chore: remove .s3cfg_registry from repo 2026-03-07 08:28:19 +04:00
cairnr01probe a4a32aaaa8 feat: define Function and Trigger CRD types
Function:
  - runtime (go1.21/python3.11/nodejs20)
  - entrypoint, s3Bucket, s3Key
  - memoryMB (default 128), timeoutSec (default 30)
  - env map
  - status: phase (Pending/Building/Ready/Failed), imageRef, conditions

Trigger:
  - type (http/cron), functionRef
  - schedule (cron only)
  - preWarmSeconds (default 300) - pre-warm before cron run
  - status: active, url, lastScheduleTime

printcolumns for kubectl get output
2026-03-07 08:24:33 +04:00
cairnr01probe abf40e8075 feat: operator-sdk scaffold with Function and Trigger CRDs
- operator-sdk init --domain kube5s.ru
- CRD: Function, Trigger (sless.kube5s.ru)
- controllers: FunctionReconciler, TriggerReconciler (stubs)
- internal/ structure: api, storage/postgres, storage/s3, builder, config
- fix: controller-tools bumped v0.11.1→v0.14.0 (Go 1.23 compat)
2026-03-07 08:16:39 +04:00
94 changed files with 5488 additions and 811 deletions
+14
View File
@@ -46,6 +46,20 @@
---
## Именование
Имена должны быть **уникальными и осмысленными по всему проекту**:
- имена файлов
- имена функций/методов
- имена переменных/констант
- имена ресурсов (Terraform, Kubernetes и т.д.)
Цель: чтобы поиск по проекту находил нужные сущности без неоднозначности, а имя сразу отражало назначение.
Запрещены безликие и повторяющиеся имена вида `handler.py`, `handle`, `data`, `value`, `temp` без контекста.
---
## Git
Коммитить и пушить после каждого завершённого этапа.
+7 -1
View File
@@ -43,6 +43,9 @@ examples/*/dist/
**/handler.zip
test.token
*.tfvars
*.tfplan
plan.out
sless-plan
.e2e-logs/
.stress-logs/
@@ -57,10 +60,13 @@ test.token
**/dist/
# дополнительные вариации переменных/файлов конфигурации
*.tfvars.json
*.tfplan
plan.out
sless-plan
examples/.git
/deployments/k8s/operator-demo.yaml
/doc/decisions/log.md
/doc/log.md
/s3cfg_registry
/sqs-operator/quick_test.sh
/sqs-operator/sqs_load_test.sh
/.s3cfg_registry
+2 -2
View File
@@ -11,8 +11,8 @@ import (
// FunctionSpec — желаемое состояние функции.
// Описывает всё необходимое для сборки и запуска пользовательского кода.
type FunctionSpec struct {
// Runtime — язык и версия выполнения (go1.21, python3.11, nodejs20)
// +kubebuilder:validation:Enum=go1.21;python3.11;nodejs20
// Runtime — язык и версия выполнения (go1.23, python3.11, nodejs20)
// +kubebuilder:validation:Enum=go1.23;python3.11;nodejs20
// +kubebuilder:validation:Required
Runtime string `json:"runtime"`
@@ -65,10 +65,10 @@ spec:
format: int32
type: integer
runtime:
description: Runtime — язык и версия выполнения (go1.21, python3.11,
description: Runtime — язык и версия выполнения (go1.23, python3.11,
nodejs20)
enum:
- go1.21
- go1.23
- python3.11
- nodejs20
type: string
+7
View File
@@ -20,6 +20,13 @@ rules:
- get
- list
- watch
- apiGroups:
- ""
resources:
- secrets
verbs:
- create
- get
- apiGroups:
- ""
resources:
+58 -4
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-03-08
// Изменено: 2026-03-11
// FunctionReconciler — основной контроллер оператора.
// Следит за CRD Function и управляет lifecycle функции:
// Pending → Building (запуск kaniko Job) → Ready (образ собран, Deployment создан) / Failed
@@ -7,9 +7,12 @@
package controllers
import (
"bytes"
"context"
"fmt"
"io"
"sort"
"strings"
"time"
appsv1 "k8s.io/api/apps/v1"
@@ -19,12 +22,14 @@ import (
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/kubernetes"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/builder"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/harbor"
)
// FunctionReconciler reconciles a Function object
@@ -32,8 +37,10 @@ type FunctionReconciler struct {
client.Client
Scheme *runtime.Scheme
Builder *builder.Builder
RegistrySecret string // имя Secret с docker credentials (для imagePullSecrets в подах функций)
OperatorNamespace string // namespace оператора — откуда копируем RegistrySecret в sless-fn-*
KubeClient kubernetes.Interface // typed client для чтения логов build-подов
RegistrySecret string // имя Secret с docker credentials (для imagePullSecrets в подах функций)
OperatorNamespace string // namespace оператора — откуда копируем RegistrySecret в sless-fn-*
HarborClient *harbor.Client // nil — Harbor не используется, EnsureProject пропускается
}
//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=functions,verbs=get;list;watch;create;update;patch;delete
@@ -165,7 +172,13 @@ func (r *FunctionReconciler) checkBuild(ctx context.Context, fn *slessv1alpha1.F
return ctrl.Result{Requeue: true}, nil
case "failed":
return r.setFailed(ctx, fn, "build job failed")
// Захватываем логи build-пода чтобы разработчик видел причину ошибки (pip error и т.д.).
logs := getBuildPodLogs(ctx, r.KubeClient, r.OperatorNamespace, jobName)
msg := "build job failed"
if logs != "" {
msg = "build job failed:\n" + logs
}
return r.setFailed(ctx, fn, msg)
}
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
@@ -183,6 +196,13 @@ func (r *FunctionReconciler) ensureDeployment(ctx context.Context, fn *slessv1al
if err := r.Create(ctx, ns); err != nil {
return ctrl.Result{}, fmt.Errorf("create function namespace: %w", err)
}
// Создаём Harbor-проект для namespace сразу при создании k8s NS (best-effort).
// Если не удалось — EnsureProject повторит вызов внутри Build().
if r.HarborClient != nil {
if err := r.HarborClient.EnsureProject(ctx, fn.Namespace); err != nil {
log.FromContext(ctx).Error(err, "harbor ensure project on ns create", "project", fn.Namespace)
}
}
} else {
return ctrl.Result{}, fmt.Errorf("get function namespace: %w", err)
}
@@ -380,3 +400,37 @@ func (r *FunctionReconciler) SetupWithManager(mgr ctrl.Manager) error {
For(&slessv1alpha1.Function{}).
Complete(r)
}
// getBuildPodLogs возвращает логи (stderr+stdout) пода kaniko build Job'а.
// Используется чтобы пробросить ошибку pip/kaniko в Function.Status.Message.
// Возвращает не более 50 последних строк — достаточно для диагностики, не засоряет CRD.
// Если логи недоступны — возвращает пустую строку (caller покажет generic msg).
func getBuildPodLogs(ctx context.Context, kube kubernetes.Interface, namespace, jobName string) string {
if kube == nil {
return ""
}
pods, err := kube.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
LabelSelector: "job-name=" + jobName,
})
if err != nil || len(pods.Items) == 0 {
return ""
}
req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{})
stream, err := req.Stream(ctx)
if err != nil {
return ""
}
defer stream.Close()
buf := new(bytes.Buffer)
_, _ = io.Copy(buf, stream)
raw := strings.TrimSpace(buf.String())
if raw == "" {
return ""
}
// Оставляем последние 50 строк — ошибки pip всегда в конце вывода.
lines := strings.Split(raw, "\n")
if len(lines) > 50 {
lines = lines[len(lines)-50:]
}
return strings.Join(lines, "\n")
}
+56 -10
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-03-09 (feature B: захват stdout пода Job в status.Message)
// Изменено: 2026-03-17 20:00 (bugfix: job-name label удалён в k8s 1.27+, split-brain cached client)
// FunctionJobReconciler — контроллер одноразовых запусков функций.
// При создании FunctionJob:
// 1. Ждёт пока Function станет Ready
@@ -134,6 +134,13 @@ func (r *FunctionJobReconciler) Reconcile(ctx context.Context, req ctrl.Request)
// Автоудаление через 10 мин после завершения — чтобы не засорять кластер
TTLSecondsAfterFinished: &ttl,
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
"managed-by": "sless",
"functionjob": fj.Name,
"function": fn.Name,
},
},
Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyNever,
// Используем тот же образ что и Deployment функции
@@ -147,11 +154,11 @@ func (r *FunctionJobReconciler) Reconcile(ctx context.Context, req ctrl.Request)
// runner читает SLESS_EVENT и вызывает handle(event) один раз
Command: runtimeRunnerCommand(fn.Spec.Runtime),
Env: append(
fnEnvVars(fn),
corev1.EnvVar{
append(fnEnvVars(fn), corev1.EnvVar{
Name: "SLESS_EVENT",
Value: eventJSON,
},
}),
goJobModeEnv(fn.Spec.Runtime)...,
),
Resources: corev1.ResourceRequirements{
Limits: corev1.ResourceList{
@@ -194,12 +201,19 @@ func (r *FunctionJobReconciler) syncJobStatus(ctx context.Context, fj *slessv1al
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseSucceeded
fj.Status.CompletionTime = &now
// Захватываем stdout пода — это return value функции (runner делает print(json.dumps(result)))
fj.Status.Message = getJobPodOutput(ctx, r.KubeClient, job.Namespace, job.Name)
fj.Status.Message = getJobPodOutput(ctx, r.KubeClient, job.Namespace, "functionjob="+fj.Name)
} else if job.Status.Failed > 0 {
now := metav1.Now()
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseFailed
fj.Status.CompletionTime = &now
fj.Status.Message = "job failed, check pod logs: kubectl logs -n sless-fn-" + fj.Namespace + " -l functionjob=" + fj.Name
// Захватываем логи по нашему лейблу functionjob= (работает во всех версиях k8s).
// Устаревший job-name= удалён в k8s 1.27+, batch.kubernetes.io/job-name= — только с 1.27.
podOutput := strings.TrimSpace(getJobPodOutput(ctx, r.KubeClient, job.Namespace, "functionjob="+fj.Name))
if podOutput == "" || podOutput == "completed successfully" {
fj.Status.Message = "job failed, check pod logs: kubectl logs -n " + job.Namespace + " -l functionjob=" + fj.Name
} else {
fj.Status.Message = "job failed: " + truncateForStatus(podOutput, 2000)
}
} else {
// Job ещё выполняется — перечитаем через 5 секунд
if err := r.Status().Update(ctx, fj); err != nil {
@@ -213,10 +227,27 @@ func (r *FunctionJobReconciler) syncJobStatus(ctx context.Context, fj *slessv1al
return ctrl.Result{}, nil
}
// truncateForStatus ограничивает длину текста для безопасной записи в status.message.
func truncateForStatus(message string, maxLen int) string {
if len(message) <= maxLen {
return message
}
if maxLen <= 3 {
return message[:maxLen]
}
return message[:maxLen-3] + "..."
}
// runtimeRunnerCommand возвращает CMD для запуска одноразового runner вместо HTTP-сервера.
// runner читает env SLESS_EVENT и SLESS_ENTRYPOINT, вызывает handle(event) один раз и завершается.
func runtimeRunnerCommand(runtime string) []string {
switch runtime {
case "go1.23":
// Go runtime: SLESS_MODE=job заставляет server читать SLESS_EVENT и выйти.
// CMD остаётся как в образе (/server), переопределяем через Env.
// Передаём пустую команду — используется CMD из образа (/server).
// SLESS_MODE=job добавляется через Env в fnEnvVars.
return nil // nil = использовать CMD из образа; SLESS_MODE=job в Env
case "nodejs20":
// inline runner — не требует отдельного файла в образе.
// SLESS_ENTRYPOINT="module.func": module=имя файла, func=экспортируемая функция
@@ -265,16 +296,31 @@ func fnEnvVars(fn *slessv1alpha1.Function) []corev1.EnvVar {
func int32Ptr(i int32) *int32 { return &i }
// getJobPodOutput находит под созданный Job-ом и возвращает его stdout (trimmed).
// runner.py/runner.js печатают json.dumps(result) в stdout — это и есть return value функции.
// goJobModeEnv возвращает SLESS_MODE=job для Go runtime — сигнал /server выполниться разово и выйти.
// Для Python/Node runner задаётся через Command, для Go — через env (CMD /server общий).
func goJobModeEnv(runtime string) []corev1.EnvVar {
if runtime == "go1.23" {
return []corev1.EnvVar{{Name: "SLESS_MODE", Value: "job"}}
}
return nil
}
// getJobPodOutput находит под по labelSelector и возвращает его stdout+stderr (trimmed).
// runner.py/runner.js печатают json.dumps(result) в stdout — return value функции.
// Исключения/трейсбэки Python/Node пишут в stderr — поэтому собираем оба потока.
// Если под не найден или логи недоступны — возвращает "completed successfully" как fallback.
func getJobPodOutput(ctx context.Context, kube kubernetes.Interface, namespace, jobName string) string {
// labelSelector передаётся снаружи — вызывающий код использует "functionjob=<name>" (наш лейбл,
// выставляется на PodTemplate контроллером и не зависит от версии k8s).
// НЕ использовать "job-name=" — этот встроенный лейбл удалён в k8s 1.27+ (у нас 1.34.1).
func getJobPodOutput(ctx context.Context, kube kubernetes.Interface, namespace, labelSelector string) string {
pods, err := kube.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
LabelSelector: "job-name=" + jobName,
LabelSelector: labelSelector,
})
if err != nil || len(pods.Items) == 0 {
return "completed successfully"
}
// Stdout: true, Stderr: true — собираем оба потока.
// Python исключения идут в stderr, runner.py пишет результат в stdout.
req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{})
stream, err := req.Stream(ctx)
if err != nil {
+107
View File
@@ -0,0 +1,107 @@
# 2026-03-18
# funcs-service.yaml — глобальный сервис листинга функций для всех пользователей.
# Развёртывается ОДИН РАЗ в namespace sless рядом с оператором.
# Доступен по: https://sless.kube5s.ru/funcs (с Bearer токеном пользователя)
#
# Обновить образ и применить:
# docker push naeel/sless-funcs-service:v0.1.0
# kubectl apply -f deployments/k8s/funcs-service.yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: sless-funcs-service
namespace: sless
labels:
app: sless-funcs-service
spec:
replicas: 1
selector:
matchLabels:
app: sless-funcs-service
template:
metadata:
labels:
app: sless-funcs-service
spec:
containers:
- name: funcs
image: naeel/sless-funcs-service:v0.1.3
ports:
- containerPort: 8090
env:
- name: SLESS_OPERATOR_URL
value: "http://sless-operator.sless.svc.cluster.local:9090"
- name: SLESS_EXTERNAL_URL
value: "https://sless.kube5s.ru"
# Системные функции, скрытые из листинга
- name: SLESS_EXCLUDE
value: "event-writer,event-monitor,event-cleaner"
# Токен сервиса задаётся через kubectl set env или Secret — не хранится в репо
# kubectl set env deployment/sless-funcs-service -n sless SLESS_SERVICE_TOKEN="$(cat secrets/test.token)"
- name: PORT
value: "8090"
livenessProbe:
httpGet:
path: /health
port: 8090
initialDelaySeconds: 5
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 8090
initialDelaySeconds: 3
periodSeconds: 10
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
cpu: 100m
memory: 64Mi
---
apiVersion: v1
kind: Service
metadata:
name: sless-funcs-service
namespace: sless
spec:
selector:
app: sless-funcs-service
ports:
- port: 8090
targetPort: 8090
---
# Отдельный Ingress для /funcs — nginx выбирает более специфичный путь перед /
# Без rewrite: сервис сам обрабатывает /funcs path
# TLS-сертификат sless-operator-tls уже управляется cert-manager через ingress оператора;
# здесь только ссылаемся на существующий секрет без аннотации cert-manager.io/cluster-issuer.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: sless-funcs-ingress
namespace: sless
annotations:
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
rules:
- host: sless.kube5s.ru
http:
paths:
- path: /funcs
pathType: Prefix
backend:
service:
name: sless-funcs-service
port:
number: 8090
tls:
- hosts:
- sless.kube5s.ru
secretName: sless-operator-tls
+122
View File
@@ -0,0 +1,122 @@
# Изменено: 2026-03-14
# Node-RED для визуального управления demo сценарием Event Log.
# Образ: nodered/node-red:4 (официальный, всегда есть на DockerHub)
# UI доступен по http://<ingress-ip>/nodered/
# Для AMQP нужен плагин node-red-contrib-amqp — ставится через initContainer.
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: nodered-data
namespace: sless
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nodered
namespace: sless
labels:
app: nodered
spec:
replicas: 1
selector:
matchLabels:
app: nodered
template:
metadata:
labels:
app: nodered
spec:
securityContext:
fsGroup: 1000
# initContainer устанавливает AMQP-плагин в PVC до старта основного контейнера
initContainers:
- name: install-nodes
image: nodered/node-red:latest
securityContext:
runAsUser: 0
runAsGroup: 0
command:
- sh
- -c
- |
chown -R 1000:1000 /data || true
cd /data
npm install --prefix /data node-red-contrib-amqp node-red-dashboard 2>&1 || true
volumeMounts:
- name: data
mountPath: /data
containers:
- name: nodered
image: nodered/node-red:latest
ports:
- containerPort: 1880
env:
- name: NODE_RED_ENABLE_PROJECTS
value: "false"
- name: TZ
value: "Europe/Moscow"
securityContext:
runAsUser: 1000
runAsGroup: 1000
volumeMounts:
- name: data
mountPath: /data
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
readinessProbe:
httpGet:
path: /
port: 1880
initialDelaySeconds: 15
periodSeconds: 10
volumes:
- name: data
persistentVolumeClaim:
claimName: nodered-data
---
apiVersion: v1
kind: Service
metadata:
name: nodered
namespace: sless
spec:
selector:
app: nodered
ports:
- port: 1880
targetPort: 1880
---
# Ingress на Node-RED — доступен снаружи по http://nodered.185.247.187.147.nip.io
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: nodered
namespace: sless
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
ingressClassName: nginx
rules:
- host: nodered.185.247.187.147.nip.io
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: nodered
port:
number: 1880
+20 -18
View File
@@ -1,20 +1,14 @@
# Изменено: 2026-03-07
# Изменено: 2026-03-11
# Деплой sless оператора в кластер.
# Состав:
# - ConfigMap: не-секретные env vars (S3_ENDPOINT, REGISTRY_HOST и т.д.)
# - Secret: секретные данные (S3 keys, postgres DSN, API token, docker auth)
# - Deployment: оператор naeel/sless-operator:v0.1.23 в namespace sless
# - Secret: секретные данные (S3 keys, postgres DSN, API token, Harbor pass)
# - Deployment: оператор naeel/sless-operator:v0.1.33 в namespace sless
# - Service: ClusterIP :9090 (REST API)
# - Ingress: sless-api.kube5s.ru → :9090 (внешний доступ с TLS)
# - Ingress: sless.kube5s.ru → :9090 (внешний доступ с TLS)
#
# Перед применением:
# kubectl create secret generic sless-operator-secret \
# --namespace=sless \
# --from-literal=POSTGRES_DSN="..." \
# --from-literal=S3_ACCESS_KEY="..." \
# --from-literal=S3_SECRET_KEY="..." \
# --from-literal=SLESS_API_TOKEN="change-me" \
# --dry-run=client -o yaml | kubectl apply -f -
# Чтобы сменить registry — менять только REGISTRY_HOST в ConfigMap.
# Чтобы сменить Harbor-аккаунт — менять HARBOR_USER в ConfigMap + HARBOR_PASS в Secret.
#
# Применение: kubectl apply -f deployments/k8s/operator.yaml
---
@@ -27,13 +21,18 @@ data:
S3_ENDPOINT: "s3.msk-1.ngcloud.ru"
S3_BUCKET: "sless-functions"
S3_USE_SSL: "true"
REGISTRY_HOST: "naeel"
# REGISTRY_HOST — единственное место, где прописан адрес registry.
# Чтобы сменить реестр — менять только здесь.
# Harbor: pearlharbor.registryk8s.services.ngcloud.ru
# DockerHub (legacy): naeel
REGISTRY_HOST: "pearlharbor.registryk8s.services.ngcloud.ru"
REGISTRY_SECRET: "sless-registry-auth"
HARBOR_USER: "admin"
API_PORT: "9090"
INGRESS_HOST: "fn.kube5s.ru"
# EXTERNAL_URL — если задан, URL функции = EXTERNAL_URL/fn/{namespace}/{name}
# Позволяет обойтись без wildcard DNS *.fn.kube5s.ru
EXTERNAL_URL: "https://sless-api.kube5s.ru"
EXTERNAL_URL: "https://sless.kube5s.ru"
---
# Secret создаётся отдельно через kubectl (не коммитить секреты в git!)
# Описание ключей:
@@ -41,13 +40,16 @@ data:
# S3_ACCESS_KEY — ключ доступа к S3/Ceph
# S3_SECRET_KEY — секретный ключ S3/Ceph
# SLESS_API_TOKEN — токен аутентификации API
# HARBOR_PASS — пароль Harbor API (для EnsureProject, не для kaniko push)
#
# Пример создания:
# kubectl create secret generic sless-operator-secret -n sless \
# --from-literal=POSTGRES_DSN="postgres://sless:PASSWORD@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable" \
# --from-literal=S3_ACCESS_KEY="ACCESS_KEY" \
# --from-literal=S3_SECRET_KEY="SECRET_KEY" \
# --from-literal=SLESS_API_TOKEN="your-token-here"
# --from-literal=SLESS_API_TOKEN="your-token-here" \
# --from-literal=HARBOR_PASS="harbor-admin-password"
# kubectl apply -f hack/create-registry-secret.sh # docker-креды для kaniko
---
apiVersion: apps/v1
kind: Deployment
@@ -70,7 +72,7 @@ spec:
containers:
- name: operator
# При обновлении версии оператора — менять тег здесь (не latest!)
image: naeel/sless-operator:v0.1.23
image: naeel/sless-operator:v0.1.33
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
imagePullPolicy: Always
ports:
@@ -131,7 +133,7 @@ metadata:
spec:
ingressClassName: nginx
rules:
- host: sless-api.kube5s.ru
- host: sless.kube5s.ru
http:
paths:
- path: /
@@ -143,5 +145,5 @@ spec:
number: 9090
tls:
- hosts:
- sless-api.kube5s.ru
- sless.kube5s.ru
secretName: sless-operator-tls
+73
View File
@@ -0,0 +1,73 @@
# Изменено: 2026-03-14
# RabbitMQ для demo сценария Event Log.
# Используется официальный образ с management-плагином для веб-UI.
# credentials: sless / sless123
# AMQP: amqp://sless:sless123@rabbitmq.sless.svc.cluster.local:5672/
# Management UI: http://rabbitmq.sless.svc.cluster.local:15672/
---
apiVersion: v1
kind: Secret
metadata:
name: rabbitmq-secret
namespace: sless
stringData:
RABBITMQ_DEFAULT_USER: "sless"
RABBITMQ_DEFAULT_PASS: "sless123"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: rabbitmq
namespace: sless
labels:
app: rabbitmq
spec:
replicas: 1
selector:
matchLabels:
app: rabbitmq
template:
metadata:
labels:
app: rabbitmq
spec:
containers:
- name: rabbitmq
image: rabbitmq:3.13-management-alpine
ports:
- name: amqp
containerPort: 5672
- name: management
containerPort: 15672
envFrom:
- secretRef:
name: rabbitmq-secret
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
readinessProbe:
tcpSocket:
port: 5672
initialDelaySeconds: 40
periodSeconds: 10
timeoutSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: rabbitmq
namespace: sless
spec:
selector:
app: rabbitmq
ports:
- name: amqp
port: 5672
targetPort: 5672
- name: management
port: 15672
targetPort: 15672
+38 -8
View File
@@ -1,5 +1,7 @@
# API Design
Последнее обновление: 2026-03-18
## Базовый URL
```
@@ -7,15 +9,38 @@ http://<operator-host>:9090/v1
```
Локально: `http://localhost:9090/v1`
В кластере (будущее): `http://sless-operator.sless.svc.cluster.local:9090/v1`
В кластере: `http://sless-operator.sless.svc.cluster.local:9090/v1`
Публично: `https://sless.kube5s.ru/v1/...` (через Ingress)
**Реализовано** (текущий namespace-aware формат):
**Реализованные эндпоинты:**
```
/v1/namespaces/{namespace}/functions[/{name}]
/v1/namespaces/{namespace}/functions/{name}/upload
/v1/namespaces/{namespace}/triggers[/{name}]
/v1/namespaces/{namespace}/functions/{name}/invocations
GET /v1/namespaces/{ns}/functions
POST /v1/namespaces/{ns}/functions
GET /v1/namespaces/{ns}/functions/{name}
PUT /v1/namespaces/{ns}/functions/{name}
DELETE /v1/namespaces/{ns}/functions/{name}
POST /v1/namespaces/{ns}/functions/{name}/upload
GET /v1/namespaces/{ns}/functions/{name}/source ← файлы кода из S3 tar.gz (JSON)
GET /v1/namespaces/{ns}/functions/{name}/invocations
GET /v1/namespaces/{ns}/triggers
POST /v1/namespaces/{ns}/triggers
GET /v1/namespaces/{ns}/triggers/{name}
PATCH /v1/namespaces/{ns}/triggers/{name} ← {"enabled": bool}
DELETE /v1/namespaces/{ns}/triggers/{name}
POST /v1/namespaces/{ns}/jobs
GET /v1/namespaces/{ns}/jobs/{name}
DELETE /v1/namespaces/{ns}/jobs/{name}
```
**Глобальный сервис funcs (не оператор):**
```
GET https://sless.kube5s.ru/funcs/<namespace> ← plain text (курл) / HTML (браузер)
GET https://sless.kube5s.ru/funcs?token=<jwt> ← редирект по namespace
GET https://sless.kube5s.ru/funcs/<namespace>/source/<fn> ← прокси к GET /source
PATCH https://sless.kube5s.ru/funcs/<namespace>/triggers/<name> ← прокси к PATCH /triggers
GET https://sless.kube5s.ru/health ← liveness probe
```
## Аутентификация
@@ -24,6 +49,11 @@ http://<operator-host>:9090/v1
Authorization: Bearer <cloud-token>
```
Токен — JWT от `auth-api`. Middleware в операторе:
1. Извлекает `sub` из payload (без проверки подписи — доверяет Ingress)
2. Вычисляет namespace: `SHA256(sub)[:8]` hex → `sless-{16 hex символов}`
3. Проверяет что запрошенный `{namespace}` совпадает с вычисленным
## Ресурсы
### Functions
@@ -87,8 +117,8 @@ field: code = <zip-file>
## Поддерживаемые runtime (v1)
- `python3.11` — реализован и протестирован
- `go1.21` — планируется
- `nodejs20` — планируется
- `nodejs20` — реализован и протестирован
- `go1.23` — реализован
## Модель Function
@@ -0,0 +1,368 @@
# Agent Handoff — 2026-03-18 (финальное состояние сессии)
Этот файл — полный срез для нового агента: что сделано, как устроено, как работать.
---
## 1. Идентификация проекта
- **Репозиторий:** `gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless`
- **Локальная копия:** `/home/naeel/remote_dev/sless/`
- **Remote server:** `naeel@5.172.178.213` (workspace: `~/terra/sless/`)
- **SSH ключ:** `/home/naeel/remote_dev/sless/secrets/naeel_vm_id_ed25519`
- **SSH команда:** `ssh -i <ключ> -o StrictHostKeyChecking=no naeel@5.172.178.213`
- **Активная ветка:** `feat/web-console` (последний коммит `a04dfb2`)
- **Git origin:** `https://gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless.git`
### Важно про git
Git **не работает локально** (зависает при записи объектов из-за NFS-подобного поведения volume).
Все `git add / commit / push` — **только через SSH на remote machine**.
`scp` для копирования файлов → `ssh` для git-операций.
---
## 2. Что такое проект
Managed Serverless Functions Service для облачного провайдера **nubes.ru**.
Пользователь пишет `main.tf` с ресурсами `sless_function`, `sless_trigger`, `sless_job`.
Terraform провайдер собирает zip → загружает в оператор → оператор запускает kaniko → Docker образ → Deployment в k8s.
**Внешнее API оператора:** `https://sless.kube5s.ru`
**Ingress IP:** `185.247.187.147`
**Namespace оператора в k8s:** `sless`
---
## 3. Стек и компоненты
| Компонент | Технология | Namespace / где |
|-----------|-----------|-----------------|
| Operator (API + Controllers) | Go (controller-runtime) | k8s namespace `sless` |
| funcs-service (web-консоль) | Go (net/http) | k8s namespace `sless` |
| PostgreSQL | PostgreSQL 16 | k8s namespace `sless` |
| S3 | Ceph (облачный) | `s3.msk-1.ngcloud.ru` |
| Container Registry | DockerHub (`naeel/`) | внешний |
| Builder | kaniko Job | namespace пользователя |
| Function (HTTP trigger) | k8s Deployment + Service | namespace пользователя |
| Function (one-shot) | k8s Job | namespace пользователя |
| Function (cron) | k8s CronJob | namespace пользователя |
| Terraform Provider | Go (plugin-framework v6) | localhost/CI |
---
## 4. Текущие версии образов
| Образ | Версия | Что внутри |
|-------|--------|-----------|
| `naeel/sless-operator` | **v0.1.34** | REST API + k8s controllers; GET /source; proxy-готовый PATCH /triggers |
| `naeel/sless-funcs-service` | **v0.2.0** | HTML web-консоль + plain text (backward compat) |
| `naeel/sless-runtime-python3.11` | **v0.1.3** | str return → text/plain |
| `naeel/sless-runtime-nodejs20` | **v0.1.2** | без изменений |
| `naeel/sless-runtime-go1.23` | **v0.1.0** | без изменений |
---
## 5. Структура директорий (актуальная)
```
sless/
├── main.go # точка входа оператора (controller-runtime + HTTP сервер)
├── Dockerfile # сборка оператора
├── go.mod # module: gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless
├── api/v1alpha1/ # CRD типы: Function, FunctionJob, Trigger
│ ├── function_types.go
│ ├── job_types.go
│ └── trigger_types.go
├── controllers/ # k8s reconcilers
│ ├── function_controller.go # Function CRD → kaniko → Deployment/Service
│ ├── functionjob_controller.go # FunctionJob CRD → k8s Job → собирает stdout/stderr
│ └── trigger_controller.go # Trigger CRD → Deployment scale / CronJob
├── internal/
│ ├── api/
│ │ ├── router.go # gorilla/mux: все REST маршруты (актуальный)
│ │ ├── middleware/ # Auth (JWT→namespace), Logging
│ │ └── handler/
│ │ ├── handler.go # Handler struct (K8s, S3, PG, Log)
│ │ ├── functions.go # CRUD Functions
│ │ ├── triggers.go # CRUD Triggers + UpdateTrigger (PATCH enabled)
│ │ ├── upload.go # POST /upload — zip → tar.gz → S3 → Function CRD patch
│ │ ├── source.go # GET /source — tar.gz из S3 → JSON файлы (НОВЫЙ)
│ │ ├── jobs.go # CRUD FunctionJobs
│ │ ├── invocations.go # logs из Postgres
│ │ ├── invoke.go # прокси вызова HTTP функций
│ │ └── namespace.go # EnsureNamespace
│ ├── builder/
│ │ └── context.go # zip + runtime → tar.gz + Dockerfile для kaniko
│ ├── storage/
│ │ ├── s3/client.go # minio-go: Upload, UploadContext, Download, Delete
│ │ └── postgres/ # хранение invocation logs
│ └── config/ # env vars конфиг
├── services/
│ └── funcs/
│ ├── main.go # web-консоль сервис (v0.2.0)
│ ├── index.html # HTML шаблон (embed)
│ ├── Dockerfile # multi-stage Go → alpine
│ └── funcs-service.yaml # (дубль, не деплоится отсюда)
├── deployments/k8s/
│ ├── operator.yaml # ConfigMap + Secret + Deployment + Service + Ingress оператора
│ ├── funcs-service.yaml # Deployment + Service + Ingress funcs-service
│ ├── postgres.yaml # PostgreSQL
│ └── rbac.yaml # ClusterRole для оператора
├── terraform/provider/ # terraform-provider-sless
│ ├── main.go
│ └── internal/
│ ├── client/client.go # HTTP клиент к оператору
│ └── resources/
│ ├── function_resource.go # sless_function: source_dir→zip, code_hash, ModifyPlan
│ ├── trigger_resource.go # sless_trigger
│ └── job_resource.go # sless_job + ErrJobAlreadyExists handling
├── runtimes/
│ ├── python3.11/server.py # HTTP wrapper (str → text/plain)
│ ├── nodejs20/ # HTTP wrapper
│ └── go1.23/ # multi-stage builder образ
├── examples/
│ ├── POSTGRES/ # pg функции: create-table, pg-info, pg-table-reader
│ ├── hello-go/
│ ├── hello-node/
│ └── ...
├── migrations/001_initial.sql # PostgreSQL схема
└── doc/ # ← ты здесь
├── architecture/
│ └── agent-handoff-2026-03-18.md ← ЭТОТ ФАЙЛ
├── api/design.md
├── decisions/log.md
├── errors/log.md
└── progress.md
```
---
## 6. REST API оператора — полный список маршрутов
Все `/v1/` защищены JWT (middleware.Auth проверяет Bearer токен + namespace).
`/fn/` — публичный прокси для HTTP-триггеров (без auth).
```
POST /v1/namespaces/{ns}/ensure # создать namespace (идемпотентно)
GET /v1/namespaces/{ns}/functions # список функций
POST /v1/namespaces/{ns}/functions # создать функцию
GET /v1/namespaces/{ns}/functions/{name} # получить функцию
PUT /v1/namespaces/{ns}/functions/{name} # обновить функцию
DELETE /v1/namespaces/{ns}/functions/{name} # удалить функцию
POST /v1/namespaces/{ns}/functions/{name}/upload # загрузить zip → S3 → kaniko
GET /v1/namespaces/{ns}/functions/{name}/source # НОВЫЙ: файлы кода из S3 (JSON)
GET /v1/namespaces/{ns}/functions/{name}/invocations # логи вызовов
GET /v1/namespaces/{ns}/triggers # список триггеров
POST /v1/namespaces/{ns}/triggers # создать триггер
GET /v1/namespaces/{ns}/triggers/{name} # получить триггер
PATCH /v1/namespaces/{ns}/triggers/{name} # enable/disable: {"enabled": bool}
DELETE /v1/namespaces/{ns}/triggers/{name} # удалить триггер
POST /v1/namespaces/{ns}/jobs # создать FunctionJob
GET /v1/namespaces/{ns}/jobs/{name} # получить FunctionJob
DELETE /v1/namespaces/{ns}/jobs/{name} # удалить FunctionJob
GET/POST/... /fn/{namespace}/{name}[/...] # вызов HTTP функции (без auth)
```
---
## 7. funcs-service — маршруты
Сервис `sless-funcs-service`, namespace `sless`, порт 8090.
Ingress: `sless.kube5s.ru/funcs` → `sless-funcs-service:8090`.
```
GET /health # liveness/readiness probe (без auth)
GET /funcs # usage hint (нет токена → 401 с подсказкой)
GET /funcs?token=<jwt> # листинг через JWT
GET /funcs/<namespace> # листинг по namespace (браузер→HTML, curl→plain text)
GET /funcs/<namespace>/source/<fn> # прокси → оператор GET /source (serviceToken)
PATCH /funcs/<namespace>/triggers/<name> # прокси → оператор PATCH /triggers (serviceToken)
```
Логика переключения HTML/plain text: `strings.Contains(Accept header, "text/html")`.
Браузер всегда шлёт `text/html` в Accept → HTML консоль.
`curl` без `-H "Accept: text/html"` → plain text (совместимость с v0.1.x).
**Env vars funcs-service:**
```
SLESS_OPERATOR_URL = http://sless-operator.sless.svc.cluster.local:9090
SLESS_EXTERNAL_URL = https://sless.kube5s.ru
SLESS_EXCLUDE = (список функций скрытых из листинга, через запятую)
SLESS_SERVICE_TOKEN = <JWT> (задаётся через kubectl set env, НЕ в git)
PORT = 8090
```
---
## 8. Namespace пользователя
```
JWT.sub (UUID) → SHA256(sub)[:8] → hex → "sless-" + 16 hex символов
```
Пример: sub `019cc268-6c6a-781e-8613-4bed4ec7cd20` → namespace `sless-ffd1f598c169b0ae`
Эта логика **одинакова** в трёх местах:
- `internal/api/middleware/auth.go` (оператор)
- `services/funcs/main.go` (funcs-service)
- `terraform/provider/internal/client/client.go` (провайдер)
---
## 9. S3 хранение кода — ключи
При `POST /upload` создаются **два объекта:**
```
functions/{ns}/{name}/{timestamp}.zip ← исходный код (zip от пользователя)
contexts/{ns}/{name}/{timestamp}.tar.gz ← build context для kaniko (zip + Dockerfile)
```
`Function.Spec.S3Key` хранит путь к `contexts/...`.
`GET /source` читает `Function.Spec.S3Key`, скачивает tar.gz, извлекает файлы без Dockerfile.
**Важно:** zip исходника (`functions/...`) отдельно не хранится в CRD.
Код источника берётся из tar.gz контекста — там те же файлы пользователя.
---
## 10. Terraform провайдер — sless_function
```hcl
resource "sless_function" "my_func" {
name = "my-func"
runtime = "python3.11" # python3.11 | nodejs20 | go1.23
entrypoint = "handler.handle"
source_dir = "${path.module}/code/my-func" # директория → провайдер делает zip сам
# ИЛИ:
# code_path = "./handler.zip" # готовый zip
# code_hash = filesha256("./handler.zip") # для детекции изменений
memory_mb = 128
timeout_sec = 30
env_vars = { KEY = "value" }
build_timeout_sec = 300 # ожидание kaniko (дефолт 300 сек)
}
```
При изменении файлов в `source_dir`:
- `terraform plan` → пересчитывает `code_hash` (ModifyPlan), показывает diff
- `terraform apply` → загружает новый zip, ждёт сборки → `phase = Ready`
- Новый код виден в браузере сразу после apply
---
## 11. Где что запущено (kubectl)
```bash
# Проверить поды оператора
kubectl get pods -n sless
# Проверить версию образа оператора
kubectl get deployment sless-operator -n sless -o jsonpath='{.spec.template.spec.containers[0].image}'
# Посмотреть логи оператора
kubectl logs -n sless -l app=sless-operator --tail=50
# Посмотреть логи funcs-service
kubectl logs -n sless -l app=sless-funcs-service --tail=50
# Обновить образ оператора
kubectl set image deployment/sless-operator operator=naeel/sless-operator:vX.X.X -n sless
kubectl rollout status deployment/sless-operator -n sless --timeout=90s
# Обновить образ funcs-service
kubectl set image deployment/sless-funcs-service funcs=naeel/sless-funcs-service:vX.X.X -n sless
# Посмотреть функции пользователя
kubectl get functions -n sless-ffd1f598c169b0ae
# Посмотреть триггеры
kubectl get triggers -n sless-ffd1f598c169b0ae
# Обновить SLESS_SERVICE_TOKEN (не хранится в git!)
kubectl set env deployment/sless-funcs-service -n sless SLESS_SERVICE_TOKEN=<jwt>
```
---
## 12. Тестовые данные
- **Тестовый токен:** `/home/naeel/remote_dev/sless/secrets/test.token`
- **Namespace тест-пользователя:** `sless-ffd1f598c169b0ae`
- **Рабочие функции:**
- `pg-info` (nodejs20) — читает версию PostgreSQL и счётчик строк
- `pg-table-reader` (python3.11) — читает строки из таблицы
- `pg-create-table-runner` (python3.11) — job: создаёт таблицу
- **Быстрый тест:**
```bash
# plain text список (curl)
curl https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae
# HTML консоль (браузер или curl с Accept)
curl -H "Accept: text/html" https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae
# исходный код функции
curl https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae/source/pg-info
# вызов функции
curl https://sless.kube5s.ru/fn/sless-ffd1f598c169b0ae/pg-info
```
---
## 13. Деплой нового образа — стандартный workflow
```bash
SSH="ssh -i /home/naeel/remote_dev/sless/secrets/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213"
SCP="scp -i /home/naeel/remote_dev/sless/secrets/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no"
# 1. Скопировать изменённые файлы оператора на remote
$SCP /home/naeel/remote_dev/sless/path/to/file.go naeel@5.172.178.213:~/terra/sless/path/to/file.go
# 2. Собрать образ на remote
$SSH "cd ~/terra/sless && docker build -t naeel/sless-operator:vX.X.X . 2>&1 | tail -5"
# 3. Запушить
$SSH "docker push naeel/sless-operator:vX.X.X 2>&1 | tail -3"
# 4. Задеплоить
$SSH "kubectl set image deployment/sless-operator operator=naeel/sless-operator:vX.X.X -n sless && kubectl rollout status deployment/sless-operator -n sless --timeout=90s"
# 5. Коммит (через SSH! не локально)
$SSH "cd ~/terra/sless && git add <файлы> && git commit -m 'msg' && git push origin feat/web-console"
```
Для funcs-service: путь `~/terra/sless/services/funcs/`, контейнер называется `funcs`.
---
## 14. Известные проблемы / особенности
| Проблема | Решение |
|---------|---------|
| `git` зависает локально | Только через SSH на remote machine |
| `SLESS_SERVICE_TOKEN` не в git | Задан через `kubectl set env`, при пересоздании пода — пропадёт! Нужно переставить вручную |
| `job-name=` label удалён в k8s 1.27+ | Используем свой label `functionjob=<name>` на PodTemplate (исправлено в v0.1.34) |
| S3 endpoint с SSL | `useSSL=false` для внутреннего s3, `useSSL=true` для облачного `s3.msk-1.ngcloud.ru` |
| `python3.11`: str return → text/plain | Начиная с `naeel/sless-runtime-python3.11:v0.1.3` |
---
## 15. Следующие возможные задачи (не начаты)
| Задача | Сложность | Заметки |
|--------|-----------|---------|
| Слияние `feat/web-console` в `main` (или базовую ветку) | низкая | Ветка стабильная, все тесты проходят |
| Кнопка "Обновить код" в HTML консоли (upload из браузера) | средняя | Drag&drop zip или указать source_dir |
| Обратная синхронизация (скачать код из S3 в source_dir) | средняя | terraform data source или отдельная команда |
| History/versioning (несколько версий кода) | высокая | S3 уже хранит по timestamp — нужен UI |
| SLESS_SERVICE_TOKEN из k8s Secret | низкая | Сейчас задаётся через kubectl set env — надо в YAML (sealed secret) |
| Логи функции в HTML консоли | средняя | GET /invocations уже есть в операторе |
| Публикация terraform провайдера | средняя | Terraform Registry или Gitea Releases |
+40 -1
View File
@@ -1,6 +1,6 @@
# Архитектура системы
Последнее обновление: 2026-03-11 (v0.1.22)
Последнее обновление: 2026-03-18 (v0.1.34 + funcs-service v0.2.0)
## Общее описание
@@ -13,6 +13,7 @@ Managed Serverless Functions Service для облачного провайде
| Компонент | Технология | Где запущен |
|-----------|-----------|-------------|
| Operator (API + Controllers) | Go (controller-runtime) | Kubernetes, namespace `sless` |
| funcs-service (глобальная консоль) | Go (net/http) | Kubernetes, namespace `sless` |
| PostgreSQL | PostgreSQL 16 | Kubernetes, namespace `sless` |
| S3 | Ceph (облачный) | `s3.msk-1.ngcloud.ru` |
| Container Registry | DockerHub (`naeel/`) | внешний |
@@ -25,6 +26,44 @@ Managed Serverless Functions Service для облачного провайде
> Redis и RabbitMQ — отложены до v2.
## Компонент: funcs-service
Глобальный HTTP сервис — **одна копия** на весь кластер, для всех пользователей.
```
User Browser / curl
└─► https://sless.kube5s.ru/funcs/<namespace>
└─► nginx Ingress (sless-funcs-ingress)
└─► sless-funcs-service:8090 (namespace sless)
└─► http://sless-operator.sless.svc.cluster.local:9090/v1/...
```
**Файлы:**
- `services/funcs/main.go` — логика
- `services/funcs/Dockerfile` — multi-stage Go → alpine
- `deployments/k8s/funcs-service.yaml` — Deployment + Service + Ingress
**Env vars сервиса:**
| Переменная | Значение |
|-----------|---------|
| `SLESS_OPERATOR_URL` | `http://sless-operator.sless.svc.cluster.local:9090` |
| `SLESS_EXTERNAL_URL` | `https://sless.kube5s.ru` |
| `SLESS_EXCLUDE` | `event-writer,event-monitor,event-cleaner` |
| `SLESS_SERVICE_TOKEN` | JWT токен (задаётся через `kubectl set env`, не в git) |
## Хранение кода функций в S3
```
Terraform source_dir (локально)
└─► zip → POST /upload → builder.PrepareContext()
├─► functions/{ns}/{name}/{ts}.zip ← ИСХОДНЫЙ КОД пользователя
└─► contexts/{ns}/{name}/{ts}.tar.gz ← BUILD CONTEXT для kaniko
└─► Function CRD: spec.s3Key = "contexts/..."
└─► контроллер → kaniko Job → Docker image
```
Для web-консоли: `GET /source` читает `contexts/{ns}/{name}/{ts}.tar.gz` (из `Function.Spec.S3Key`), распаковывает tar.gz, фильтрует Dockerfile, возвращает пользовательские файлы.
## Изоляция пользователей — Namespace per user
Каждый пользователь облака получает **отдельный k8s namespace**.
+90 -33
View File
@@ -1,49 +1,106 @@
# Структура проекта
Последнее обновление: 2026-03-18 (v0.1.34)
## Репозиторий
`gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless`
## Директории
## Директории (актуальное состояние)
```
sless/
├── cmd/
│ └── api/
│ └── main.go # точка входа API сервера
├── main.go # точка входа оператора (controller-runtime + HTTP сервер)
├── Dockerfile # multi-stage сборка оператора
├── go.mod # module: gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless
├── Makefile
├── api/
│ └── v1alpha1/ # CRD типы для controller-gen
│ ├── function_types.go # Function CRD: spec, status
│ ├── job_types.go # FunctionJob CRD
│ ├── trigger_types.go # Trigger CRD
│ └── zz_generated.deepcopy.go # автогенерация DeepCopy
├── controllers/
│ ├── function_controller.go # Function → kaniko Job → Deployment/Service
│ ├── functionjob_controller.go # FunctionJob → k8s Job → stdout/stderr → status
│ └── trigger_controller.go # Trigger → Deployment scale / CronJob
├── internal/
│ ├── api/
│ │ ├── handler/ # HTTP хендлеры (functions, versions, triggers)
│ │ ├── middleware/ # auth, logging, rate limit
│ │ └── router.go # регистрация маршрутов
│ ├── model/ # доменные модели: Function, Version, Trigger, Invocation
│ │ ├── router.go # gorilla/mux: регистрация всех маршрутов
│ │ ├── middleware/
│ │ │ ├── auth.go # JWT Bearer → namespace (SHA256)
│ │ │ └── logging.go # access log
│ │ └── handler/
│ │ ├── handler.go # Handler struct (K8s, S3, PG, Log)
│ │ ├── functions.go # GET/POST/PUT/DELETE функций
│ │ ├── triggers.go # GET/POST/PATCH/DELETE триггеров
│ │ ├── upload.go # POST /upload: zip → tar.gz → S3 → CRD patch
│ │ ├── source.go # GET /source: S3 tar.gz → JSON файлы (НОВЫЙ v0.1.34)
│ │ ├── jobs.go # GET/POST/DELETE FunctionJob
│ │ ├── invocations.go # GET /invocations: логи из Postgres
│ │ ├── invoke.go # прокси HTTP вызовов функций
│ │ └── namespace.go # POST /ensure: создать k8s namespace
│ ├── builder/
│ │ └── context.go # zip + runtime → tar.gz + Dockerfile для kaniko
│ ├── storage/
│ │ ├── postgres/ # CRUD функций, версий, логов вызовов
│ │ └── s3/ # загрузка/скачивание zip архивов кода
│ ├── builder/ # сборка Docker образа из кода пользователя
│ ├── runner/ # запуск функций в k8s (Jobs / Deployments)
│ └── config/ # конфиг из env переменных
├── migrations/ # SQL миграции (numbered: 001_, 002_, ...)
├── deployments/
│ └── k8s/ # манифесты: Deployment, Service, Ingress, RBAC
├── api/
│ └── openapi.yaml # OpenAPI 3.0 спецификация
├── doc/ # документация проекта (эта папка)
└── docker-compose.yml # локальная разработка: postgres, redis, minio
│ │ ├── s3/client.go # minio-go: Upload, UploadContext, Download, Delete
│ │ └── postgres/ # хранение invocation logs
│ └── config/ # конфиг из env переменных
├── services/
│ └── funcs/ # отдельный HTTP сервис (web-консоль)
│ ├── main.go # v0.2.0: HTML+plain text+proxy к оператору
│ ├── index.html # dark-themed HTML консоль (go:embed)
│ ├── Dockerfile # multi-stage Go → alpine (копирует main.go + index.html)
│ └── go.mod # отдельный Go модуль
├── migrations/
│ └── 001_initial.sql # схема PostgreSQL (invocation logs)
├── deployments/k8s/
│ ├── operator.yaml # ConfigMap + Deployment + Service + Ingress оператора
│ ├── funcs-service.yaml # Deployment + Service + Ingress funcs-service
│ ├── postgres.yaml # PostgreSQL
│ └── rbac.yaml # ClusterRole + ClusterRoleBinding для оператора
├── runtimes/
│ ├── python3.11/server.py # HTTP wrapper (str → text/plain начиная с v0.1.3)
│ ├── nodejs20/server.js # HTTP wrapper
│ └── go1.23/ # multi-stage builder образ
├── terraform/
│ └── provider/ # terraform-provider-sless (отдельный Go модуль)
│ ├── main.go
│ └── internal/
│ ├── client/client.go # REST клиент к оператору
│ ├── resources/
│ │ ├── function_resource.go # sless_function: source_dir, code_hash, ModifyPlan
│ │ ├── trigger_resource.go # sless_trigger
│ │ └── job_resource.go # sless_job + ErrJobAlreadyExists
│ └── provider/provider.go
├── examples/
│ ├── POSTGRES/ # pg функции: create-table, pg-info, pg-table-reader
│ ├── hello-go/
│ ├── hello-node/
│ └── simple-python/
├── config/ # kustomize конфиги (controller-gen артефакты)
│ ├── crd/ # CRD манифесты (автогенерация)
│ ├── rbac/
│ └── samples/
├── hack/
│ └── boilerplate.go.txt # шаблон заголовков файлов
└── doc/ # документация проекта
├── architecture/
│ ├── agent-handoff-2026-03-18.md # полный контекст для нового агента
│ ├── overview.md # архитектура системы
│ └── project-structure.md # этот файл
├── api/design.md # дизайн REST API
├── decisions/log.md # журнал архитектурных решений
├── errors/log.md # известные ошибки и решения
└── progress.md # трекер задач
```
## Go module
## Go модули
```
module gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless
```
Два независимых Go модуля в одном репозитории:
## Порядок разработки
1. `internal/config` + `internal/model` — базовые структуры данных
2. `migrations/` + `internal/storage/postgres` — схема БД и CRUD
3. `internal/api` — HTTP хендлеры, роутер, middleware
4. `internal/storage/s3` — загрузка кода функций
5. `internal/builder` — сборка Docker образов
6. `internal/runner` — запуск функций в k8s
7. `deployments/k8s` — манифесты для деплоя
| Модуль | Путь | Назначение |
|--------|------|-----------|
| `gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless` | `/` | Оператор + API |
| `gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/services/funcs` | `services/funcs/` | Web-консоль сервис |
| `terra.k8c.ru/naeel/sless` | `terraform/provider/` | Terraform провайдер |
+159
View File
@@ -0,0 +1,159 @@
# API Design
Последнее обновление: 2026-03-18
## Базовый URL
```
http://<operator-host>:9090/v1
```
Локально: `http://localhost:9090/v1`
В кластере: `http://sless-operator.sless.svc.cluster.local:9090/v1`
Публично: `https://sless.kube5s.ru/v1/...` (через Ingress)
**Реализованные эндпоинты:**
```
GET /v1/namespaces/{ns}/functions
POST /v1/namespaces/{ns}/functions
GET /v1/namespaces/{ns}/functions/{name}
DELETE /v1/namespaces/{ns}/functions/{name}
POST /v1/namespaces/{ns}/functions/{name}/upload
GET /v1/namespaces/{ns}/triggers
POST /v1/namespaces/{ns}/triggers
GET /v1/namespaces/{ns}/triggers/{name}
DELETE /v1/namespaces/{ns}/triggers/{name}
GET /v1/namespaces/{ns}/functions/{name}/invocations
```
**Запланированные эндпоинты (ветка feat/web-console):**
```
GET /v1/namespaces/{ns}/functions/{name}/source ← НОВЫЙ: код из S3 zip
PATCH /v1/namespaces/{ns}/triggers/{name} ← НОВЫЙ: enable/disable триггера
```
**Глобальный сервис funcs (не оператор):**
```
GET https://sless.kube5s.ru/funcs/<namespace> ← без токена, plain text / HTML
GET https://sless.kube5s.ru/funcs?token=<jwt> ← с токеном
GET https://sless.kube5s.ru/health ← liveness probe
```
## Аутентификация
```
Authorization: Bearer <cloud-token>
```
Токен — JWT от `auth-api`. Middleware в операторе:
1. Извлекает `sub` из payload (без проверки подписи — доверяет Ingress)
2. Вычисляет namespace: `SHA256(sub)[:8]` hex → `sless-{16 hex символов}`
3. Проверяет что запрошенный `{namespace}` совпадает с вычисленным
## Ресурсы
### Functions
| Метод | Путь | Описание |
|-------|------|----------|
| GET | /functions | Список функций |
| POST | /functions | Создать функцию |
| GET | /functions/{id} | Получить функцию |
| PUT | /functions/{id} | Обновить функцию |
| DELETE | /functions/{id} | Удалить функцию |
### Versions (код функции)
| Метод | Путь | Описание |
|-------|------|----------|
| GET | /functions/{id}/versions | Список версий |
| POST | /functions/{id}/versions | Загрузить новый код (multipart zip) |
| GET | /functions/{id}/versions/{ver} | Получить версию |
| POST | /functions/{id}/versions/{ver}/activate | Активировать версию |
### Triggers
| Метод | Путь | Описание |
|-------|------|----------|
| GET | /functions/{id}/triggers | Список триггеров |
| POST | /functions/{id}/triggers | Создать триггер (HTTP/Cron) |
| DELETE | /functions/{id}/triggers/{tid} | Удалить триггер |
### Invocations (вызов и логи)
| Метод | Путь | Описание |
|-------|------|----------|
| POST | /functions/{id}/invoke | Синхронный вызов |
| GET | /functions/{id}/invocations | История вызовов |
| GET | /functions/{id}/invocations/{iid} | Детали вызова + логи |
## Upload endpoint
```
POST /v1/namespaces/{namespace}/functions/{name}/upload
Content-Type: multipart/form-data
Authorization: Bearer <token>
field: code = <zip-file>
```
Процесс:
1. Принимает zip (max 32MB)
2. Распаковывает zip
3. Генерирует `Dockerfile` (`FROM naeel/sless-runtime-{runtime}:latest\nCOPY . /app/function/`)
4. Перепаковывает в `tar.gz` (kaniko требует tar format)
5. Загружает в S3: `contexts/{ns}/{name}/{timestamp}.tar.gz`
6. Обновляет `fn.Spec.S3Key` → контроллер видит изменение и запускает kaniko Job
Ответ `200 OK`:
```json
{"message": "build queued", "phase": "Pending", "s3_key": "contexts/..."}
```
## Поддерживаемые runtime (v1)
- `python3.11` — реализован и протестирован
- `go1.21` — планируется
- `nodejs20` — планируется
## Модель Function
```json
{
"id": "fn-uuid",
"name": "my-function",
"description": "...",
"runtime": "python3.11",
"entrypoint": "handler.handle",
"memory_mb": 128,
"timeout_sec": 30,
"env_vars": {"KEY": "value"},
"active_version": "1",
"status": "active",
"created_at": "...",
"updated_at": "..."
}
```
## Модель Trigger
```json
{
"id": "tr-uuid",
"type": "http",
"url": "https://sless.api.ngcloud.ru/invoke/fn-uuid",
"created_at": "..."
}
```
```json
{
"id": "tr-uuid",
"type": "cron",
"schedule": "0 * * * *",
"created_at": "..."
}
```
+184
View File
@@ -2,6 +2,190 @@
> Сюда записываем проблемы с которыми столкнулись и как их решили.
---
## 2026-03-17 — Баг 3: PodLogOptions compile error (v0.1.31)
**Проблема:** Оператор не компилировался. Ошибка:
```
controllers/functionjob_controller.go: unknown field Stdout in corev1.PodLogOptions
controllers/functionjob_controller.go: unknown field Stderr in corev1.PodLogOptions
```
**Причина:** `corev1.PodLogOptions{}` в k8s API не имеет полей `Stdout` и `Stderr` — это поля из `corev1.ContainerState`. Код выглядел так:
```go
req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{
Stdout: true, // не существует
Stderr: true, // не существует
})
```
**Решение:** Убрать несуществующие поля. `GetLogs` по умолчанию возвращает stdout+stderr без дополнительных флагов:
```go
req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{})
```
**Файл:** `controllers/functionjob_controller.go`, функция `getJobPodOutput`
**Версия:** исправлено в `naeel/sless-operator:v0.1.31`
---
## 2026-03-17 — Главная причина провала POSTGRES example: неправильный nubes_endpoint
### Симптом
`terraform apply` для `examples/POSTGRES` падал с `"job failed, check pod logs"`. При этом `examples/simple-python` и `examples/hello-node` работали нормально на тест-стенде.
### Корневая причина
В `examples/POSTGRES/main.tf` в блоке `provider "sless"` был указан **продовый** endpoint Nubes API:
```hcl
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
token = var.api_token
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1" # ← ПРОД
}
```
При этом `provider "nubes"` уже указывал на тест:
```hcl
provider "nubes" {
api_token = var.api_token
api_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm" # ← тест ✓
}
```
Из-за этого `sless_function` и `sless_job` стучались в **продовый** Nubes API, где у тестового пользователя другой namespace и другой кластер. Postgres и pod запускались в контексте прода, а не тест-стенда. Поды не могли подключиться к базе.
### Фикс
```hcl
# examples/POSTGRES/main.tf
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
token = var.api_token
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1" # ← тест
}
```
Аналогично исправлено в `examples/simple-python/main.tf`.
### Почему долго искали
Долгое расследование ушло на "split-brain" — API-созданные FunctionJob'ы были невидимы через `kubectl get functionjobs -A`. Это объяснялось тем что:
1. Job завершался (Failed) за ~5 секунд
2. Между завершением и опросом kubectl объект уже мог быть очищен
3. Параллельно в кластере фигурировал другой namespace (`sless-ffd1f598c169b0ae` — прод) vs тест-namespace
На самом деле контроллер работал корректно. Проблема была исключительно в `nubes_endpoint`.
### Урок
**Всегда проверять**: когда в `main.tf` два провайдера (`nubes` + `sless`) — у обоих endpoint'ы должны указывать на **одну среду**. Смешивание прод/тест endpoint'ов в одном apply даёт непредсказуемые результаты.
---
## 2026-03-17 — Ошибка агента: локальный запуск terraform
**Проблема:** Агент запускал `terraform apply` **локально** (`/home/naeel/remote_dev/sless/examples/...`) вместо запуска на удалённом сервере `naeel@5.172.178.213`.
**Почему неправильно:**
- Провайдер `terra.k8c.ru/naeel/sless` установлен только на удалённом сервере
- Локальный terraform не имеет доступа к k8s кластеру напрямую
- Потенциально затрагивал продовые API из локальной сети
**Правило:** Все `terraform init/plan/apply/destroy` для `examples/` — только через SSH на `naeel@5.172.178.213`:
```bash
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-17 — FunctionJob всегда "job failed, check pod logs" — расследование и два бага
### Хронология расследования
**Симптом:** `terraform apply` на `examples/POSTGRES` завершается ошибкой:
```
job failed: job failed, check pod logs: kubectl logs -n sless-fn-sless-ffd1f598c169b0ae -l functionjob=pg-create-table-job-main-v12
```
Команда из сообщения — ничего не выдаёт (под не найден).
---
**Ошибка агента №1 — SQL-диагностика вместо анализа кода**
Первая реакция — запустить диагностические поды в кластере с psycopg2 чтобы проверить подключение к PostgreSQL. Это было **неправильно**: проблема не в подключении, а в том что job не запускался вообще. Пользователь остановил — "ПРИ ЧЁМ тут SQL запросы???".
---
**Ошибка агента №2 — не читал собственный код**
Раньше читались логи оператора и kubectl describe — но не исходный код контроллеров. Пользователь указал: "мы пишем ВСЁ сами, смотри в код". После прочтения `functionjob_controller.go` и `functions.go` картина сложилась за одно чтение.
---
### Баг 1 — k8s label `job-name` удалён в 1.27+
**Файл:** `controllers/functionjob_controller.go`, функция `getJobPodOutput`
**Код до:**
```go
pods, err := kube.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
LabelSelector: "job-name=" + jobName,
})
if err != nil || len(pods.Items) == 0 {
return "completed successfully" // ← сюда всегда попадали
}
```
**Причина:** В k8s 1.27 лейбл `job-name` на pod-ах был deprecated, в 1.32+ удалён полностью. У нас кластер **1.34.1**. Поэтому `List` возвращал 0 подов всегда → fallback `"completed successfully"` → при `phase=Failed` финальное сообщение `"job failed, check pod logs: ..."`.
Логи реального сбоя никогда не попадали в `status.Message` FunctionJob, поэтому **корневая ошибка Python-функции была невидима**.
**Как нашли:** Проверили `kubectl get pod -l "batch.kubernetes.io/job-name"` — вернул поды. `kubectl get pod -l "job-name"` — не вернул. Версия кластера: `v1.34.1` (kubectl `v1.35.2`).
**Фикс:** Использовать собственный лейбл `functionjob=<fj.Name>`, который мы сами выставляем на PodTemplate и который работает независимо от версии k8s.
---
**Также:** В строке 211 подсказка для пользователя тоже использовала устаревший лейбл:
```go
"job failed, check pod logs: kubectl logs -n " + job.Namespace + " -l job-name=" + job.Name
```
Исправлено на `functionjob=<fj.Name>` — теперь команда реально работает.
---
### Баг 2 — Split-brain cached client при CreateFunction
**Файл:** `internal/api/handler/functions.go`, функция `CreateFunction`
**Проявление:** API возвращает `409 "function already exists"`, а `kubectl get function -A` функцию не видит. `terraform state rm sless_function.*` не помогает — следующий `terraform apply` снова получает 409.
**Причина:** `h.K8s` в handler — это **cached client** controller-runtime. Цепочка:
1. Предыдущий `terraform destroy` вызвал `DELETE /functions/pg-create-table-runner`
2. Handler вызвал `h.K8s.Delete(ctx, fn)` — объект удалён из **etcd**
3. Кеш controller-runtime обновляется асинхронно (informer watch). Несколько секунд объект ещё жив в памяти оператора
4. Следующий `terraform apply` → `POST /functions` → `h.K8s.Create(ctx, fn)` → `IsAlreadyExists` (кеш ещё видит объект)
5. Код проверяет `phase == Failed` — но объект в кеше в фазе `Ready` → уходит в 409 навсегда
`kubectl get function` шёл **мимо кеша** (прямо в k8s API) → NotFound. API handler шёл **через кеш** → AlreadyExists. Поэтому они показывали разные результаты.
**Фикс:** При `IsAlreadyExists` делать `uncached Get` (через `client.ObjectKey` напрямую). Если объект реально не найден в etcd (`IsNotFound`) — значит кеш устарел, смело создаём заново. Если найден — возвращаем 409 как обычно (объект реально существует).
---
### Итог
| # | Баг | Файл | Строка | Тип |
|---|-----|------|--------|-----|
| 1 | `LabelSelector: "job-name="` deprecated k8s 1.27+ | `controllers/functionjob_controller.go` | 311 | Совместимость |
| 1b | Подсказка `kubectl logs -l job-name=` тоже устарела | `controllers/functionjob_controller.go` | 211 | UX |
| 2 | Cached client → perpetual 409 при CreateFunction | `internal/api/handler/functions.go` | 117-133 | Race condition |
## Шаблон записи
```
+204
View File
@@ -0,0 +1,204 @@
# Архитектура системы
Последнее обновление: 2026-03-18 (v0.1.33 + funcs-service v0.1.3)
## Общее описание
Managed Serverless Functions Service для облачного провайдера nubes.ru.
Пользователь загружает код через Terraform, сервис его собирает (kaniko) и запускает
по HTTP-триггеру, расписанию (cron) или вручную через one-shot Job.
## Стек
| Компонент | Технология | Где запущен |
|-----------|-----------|-------------|
| Operator (API + Controllers) | Go (controller-runtime) | Kubernetes, namespace `sless` |
| funcs-service (глобальная консоль) | Go (net/http) | Kubernetes, namespace `sless` |
| PostgreSQL | PostgreSQL 16 | Kubernetes, namespace `sless` |
| S3 | Ceph (облачный) | `s3.msk-1.ngcloud.ru` |
| Container Registry | DockerHub (`naeel/`) | внешний |
| Builder | kaniko (k8s Job) | namespace пользователя |
| Функции (HTTP) | k8s Deployment + Service | namespace пользователя |
| Функции (one-shot) | k8s Job | namespace пользователя |
| Функции (cron) | k8s CronJob | namespace пользователя |
| Terraform Provider | Go (plugin framework v6) | localhost/CI |
| nubes API | REST (облако) | `deck-api.ngcloud.ru` |
> Redis и RabbitMQ — отложены до v2.
## Компонент: funcs-service
Глобальный HTTP сервис — **одна копия** на весь кластер, для всех пользователей.
```
User Browser / curl
└─► https://sless.kube5s.ru/funcs/<namespace>
└─► nginx Ingress (sless-funcs-ingress)
└─► sless-funcs-service:8090 (namespace sless)
└─► http://sless-operator.sless.svc.cluster.local:9090/v1/...
```
**Файлы:**
- `services/funcs/main.go` — логика
- `services/funcs/Dockerfile` — multi-stage Go → alpine
- `deployments/k8s/funcs-service.yaml` — Deployment + Service + Ingress
**Env vars сервиса:**
| Переменная | Значение |
|-----------|---------|
| `SLESS_OPERATOR_URL` | `http://sless-operator.sless.svc.cluster.local:9090` |
| `SLESS_EXTERNAL_URL` | `https://sless.kube5s.ru` |
| `SLESS_EXCLUDE` | `event-writer,event-monitor,event-cleaner` |
| `SLESS_SERVICE_TOKEN` | JWT токен (задаётся через `kubectl set env`, не в git) |
## Хранение кода функций в S3
```
Terraform source_dir (локально)
└─► zip → POST /upload → builder.PrepareContext()
├─► functions/{ns}/{name}/{ts}.zip ← ИСХОДНЫЙ КОД пользователя
└─► contexts/{ns}/{name}/{ts}.tar.gz ← BUILD CONTEXT для kaniko
└─► Function CRD: spec.s3Key = "contexts/..."
└─► контроллер → kaniko Job → Docker image
```
Для web-консоли: `GET /source` читает `functions/{ns}/{name}/{ts}.zip` напрямую.
## Изоляция пользователей — Namespace per user
Каждый пользователь облака получает **отдельный k8s namespace**.
```
JWT токен (Bearer)
└─► JWT.sub (строка "0199e325-1cdf-7cda-9319-e5302a85e291")
└─► SHA256(sub) → первые 8 байт → hex → "sless-{16 hex символов}"
└─► namespace = "sless-cdd874dfa31ba6ca"
```
- Namespace детерминирован: один sub → всегда один namespace.
- sub не раскрывается в имени namespace (SHA256 необратим).
- Длина 22 символа — укладывается в лимит k8s (63).
**Кто создаёт namespace:**
Terraform провайдер при Configure() вызывает POST /v1/namespaces/{ns}/ensure
**один раз**, до любых ресурсных операций.
Resource-хендлеры (Function, Trigger, Job) namespace **не создают** — это не их ответственность.
## Аутентификация
### Оператор (REST API)
- Bearer JWT в заголовке Authorization
- Проверяется структура JWT (3 части), наличие sub claim, срок действия exp
- Подпись **не проверяется** — trusted perimeter (оператор за Ingress)
### Terraform Provider (при Configure)
1. Декодирует JWT → sub
2. Вычисляет namespace через SHA256
3. Если задан nubes_endpoint — пингует nubes API (GET <nubes_endpoint>) с тем же токеном
- HTTP 401/403 → ошибка инициализации провайдера
- Недоступен → ошибка инициализации
4. Вызывает POST /v1/namespaces/{ns}/ensure (создаёт namespace если нет)
## Схема вызова
```
Пользователь (curl / браузер)
|
v GET|POST|... /fn/{namespace}/{name}/*
sless-api Ingress -> Operator /fn/ прокси
|
v HTTP forward -> http://{name}.{namespace}.svc.cluster.local:8080
k8s Service -> Deployment/Pod функции
```
```
terraform apply
|
v provider Configure()
1. JWT -> sub -> namespace
2. PingNubesAPI (валидация токена)
3. POST /v1/namespaces/{ns}/ensure <- создаёт k8s namespace
|
+-> POST /v1/namespaces/{ns}/functions <- создаёт Function CRD
| +-> POST /upload (zip) <- загружает код -> S3 -> kaniko Job
| +-> polling phase=Ready
|
+-> POST /v1/namespaces/{ns}/triggers <- создаёт Trigger CRD
| +-> controller: Deployment + Service + (CronJob для cron)
|
+-> POST /v1/namespaces/{ns}/jobs <- создаёт FunctionJob CRD
+-> controller: k8s Job -> result в status
```
## Структура кода
```
sless/
|-- main.go точка входа: k8s manager + REST API сервер (goroutine)
|-- internal/
| |-- api/
| | |-- router.go gorilla/mux: /fn/ (публичный), /v1/ (auth + middleware)
| | |-- handler/
| | | |-- handler.go Handler struct + helpers (writeJSON, namespace(), pathVar())
| | | |-- namespace.go EnsureNamespace (POST /v1/namespaces/{ns}/ensure)
| | | |-- functions.go CRUD Function
| | | |-- triggers.go CRUD Trigger
| | | |-- jobs.go CRUD FunctionJob
| | | |-- upload.go zip -> Dockerfile -> tar.gz -> S3 -> CRD patch
| | | |-- invoke.go прокси /fn/{ns}/{name} -> in-cluster DNS
| | | +-- invocations.go 501 stub (реализация отложена)
| | +-- middleware/
| | |-- auth.go JWT validation (struct + sub + exp, подпись не проверяется)
| | +-- logging.go slog request logger
| |-- builder/
| | |-- builder.go kaniko Job lifecycle (Build, JobStatus, Cleanup)
| | +-- context.go PrepareContext: zip+runtime → tar.gz+Dockerfile для kaniko
| |-- config/config.go Load() из env vars
| +-- storage/
| |-- postgres/store.go SaveInvocation, ListInvocations, RunMigrations
| +-- s3/client.go Upload, Download, UploadContext (tar.gz для kaniko)
|-- controllers/
| |-- function_controller.go Reconcile: Pending->Building->Ready/Failed + Deployment
| |-- trigger_controller.go Reconcile: Service+Ingress (http) / CronJob (cron)
| +-- functionjob_controller.go Reconcile: k8s Job -> Succeeded/Failed + output capture
|-- api/v1alpha1/
| |-- function_types.go Function CRD
| |-- trigger_types.go Trigger CRD
| +-- job_types.go FunctionJob CRD
|-- deployments/k8s/
| |-- operator.yaml Deployment + Service + Ingress
| +-- rbac.yaml ClusterRole + ClusterRoleBinding + ServiceAccount
|-- terraform/provider/ независимый Go-модуль
| +-- internal/
| |-- client/client.go SubFromJWT, NamespaceFromSub, PingNubesAPI + CRUD
| |-- provider/provider.go Configure(): JWT->NS->ping->EnsureNamespace
| +-- resources/
| |-- function_resource.go sless_function
| |-- trigger_resource.go sless_trigger
| +-- job_resource.go sless_job
+-- runtimes/
|-- python3.11/ server.py + Dockerfile -> naeel/sless-runtime-python3.11:v0.1.1
+-- nodejs20/ server.js + Dockerfile -> naeel/sless-runtime-nodejs20:v0.1.2
```
## Kubernetes кластер
Сейчас используется существующий кластер (временный).
Планируется переезд на новый кластер — манифесты переносятся без изменений.
Ноды:
- wheel-control-plane-fm9sr — control-plane
- wheel-workers-tv4qr-r45xs — worker
- wheel-workers-tv4qr-x8xw7 — worker
Ingress: nginx, external IP 5.172.178.182
API endpoint: https://sless-api.kube5s.ru
## Версии в production
| Артефакт | Тег/Версия |
|---------|-----------|
| naeel/sless-operator | v0.1.22 |
| terra.k8c.ru/naeel/sless провайдер | v0.1.13 |
| naeel/sless-runtime-python3.11 | v0.1.1 |
| naeel/sless-runtime-nodejs20 | v0.1.2 |
+296 -1
View File
@@ -1,6 +1,208 @@
# Прогресс разработки
Последнее обновление: 2026-03-10 (добавлен прагматичный обзор Claude Opus 4.6)
Последнее обновление: 2026-03-19 09:00
## 2026-03-19 — pg-table-writer HTML + bugfix invoke.go Content-Length (оператор v0.1.37)
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Python runtime v0.1.4: `text/html` поддержка | ✅ | `server.py`: str начинается с `<` → `text/html; charset=utf-8`; добавлен `_accept` в event |
| 2 | `internal/builder/context.go`: python runtime → v0.1.4 | ✅ | |
| 3 | Образ `naeel/sless-runtime-python3.11:v0.1.4` | ✅ | Собран и запушен |
| 4 | `code/table-rw/table_rw.py` | ✅ | Комбинированный reader+writer. `list_rows` — JSON. `add_row`: GET → HTML форма, POST form → INSERT + HTML ответ. `_render_page` шаблон с тёмной темой |
| 5 | `examples/POSTGRES/resources.tf` | ✅ | reader: `source_dir=code/table-rw`, `entrypoint=table_rw.list_rows`. Новые: `sless_function.postgres_table_writer`, `sless_trigger.postgres_table_writer_http`, `output.table_writer_url` |
| 6 | Удалена папка `code/table-reader/` | ✅ | |
| 7 | `internal/builder/builder.go`: убран `--cache=true` | ✅ | kaniko по умолчанию не кэширует слои |
| 8 | Оператор v0.1.35 | ✅ (промежуточный) | |
| 9 | Оператор v0.1.36 | ✅ | Убран ошибочный `--no-cache` (kaniko v1.24.0 не поддерживает этот флаг) |
| 10 | Оператор v0.1.37 | ✅ | **Bugfix `invoke.go`**: добавлен `proxyReq.ContentLength = r.ContentLength`. Без этого Python-сервер читал тело как 0 байт (бесконечно chunked) — форма не парсилась |
| 11 | POST через прокси | ✅ | `curl POST title=test-proxy-fix` → HTML с «Добавлено: «test-proxy-fix»», строка в таблице |
### Root cause бага формы
`internal/api/handler/invoke.go`: при проксировании POST-запроса не устанавливался `Content-Length`.
Go `http.Client` отправлял запрос без `Content-Length` → Python `BaseHTTPRequestHandler.headers.get("Content-Length", 0)` = 0 → тело не читалось → `title` пустой → JSON ошибка.
Фикс: одна строка `proxyReq.ContentLength = r.ContentLength`.
### Образы
| Образ | Версия | Что изменилось |
|-------|--------|----------------|
| `naeel/sless-operator` | v0.1.37 | `invoke.go`: `proxyReq.ContentLength = r.ContentLength` |
| `naeel/sless-runtime-python3.11` | v0.1.4 | `text/html` support, `_accept` in event |
---
## 2026-03-18 — web-консоль (оператор v0.1.34 + funcs-service v0.2.0)
## 2026-03-18 — web-консоль (оператор v0.1.34 + funcs-service v0.2.0)
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Оператор: `GET /v1/.../functions/{name}/source` | ✅ | `internal/api/handler/source.go` — скачивает tar.gz контекст из S3; извлекает пользовательские файлы (без Dockerfile); возвращает `[{name, content, binary}]` |
| 2 | Оператор: `PATCH /v1/.../triggers/{name}` | ✅ | Уже существовал в v0.1.33 — Handler обрабатывает поле `enabled` |
| 3 | `internal/api/router.go` — маршрут /source | ✅ | `GET /namespaces/{ns}/functions/{name}/source → h.GetSource` |
| 4 | `services/funcs/index.html` — HTML web-консоль | ✅ | Тёмная тема, аккордеоны, highlight.js, кнопки ▶/■ для enable/disable триггеров |
| 5 | `services/funcs/main.go` — HTML режим + proxy endpoints | ✅ | `Accept: text/html` → HTML; `GET /funcs/{ns}/source/{fn}` и `PATCH /funcs/{ns}/triggers/{name}` — прокси к оператору через SLESS_SERVICE_TOKEN |
| 6 | Backward compat: plain text | ✅ | curl без `Accept: text/html` — старое поведение v0.1.x |
| 7 | Оператор v0.1.34 | ✅ | Build+push+deploy в кластер |
| 8 | funcs-service v0.2.0 | ✅ | Build+push+deploy в кластер |
| 9 | Коммит | ✅ | `bf9f073` — `feat: web-console — HTML UI + source viewer + trigger toggle` |
### Текущие URL
```
https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae # браузер → HTML консоль
# curl → plain text (backward compat)
https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae/source/pg-info # JSON файлы функции
https://sless.kube5s.ru/fn/sless-ffd1f598c169b0ae/pg-info # вызов функции
```
### Образы
| Образ | Версия | Что изменилось |
|-------|--------|----------------|
| `naeel/sless-operator` | v0.1.34 | GET /source endpoint, HTML web-консоль proxy routes |
| `naeel/sless-funcs-service` | v0.2.0 | HTML консоль + source/trigger proxy endpoints |
---
## 2026-03-18 — FunctionJob bugfix (functionjob label + stderr capture)
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | `functionjob_controller.go`: label на PodTemplate | ✅ | Добавлен `PodTemplate.ObjectMeta.Labels: {managed-by, functionjob, function}` — k8s 1.27+ удалил встроенный `job-name=` лейбл |
| 2 | `getJobPodOutput` по `labelSelector` | ✅ | Принимает `"functionjob=<name>"` вместо `jobName`; используется для успешного и упавшего job |
| 3 | Захват stderr при ошибке Job | ✅ | При `job.Status.Failed > 0` — берём `podOutput`; если непустой — пишем в `status.Message` (truncate до 2000 символов) |
| 4 | `truncateForStatus()` helper | ✅ | Ограничивает длину `status.Message` для безопасной записи в k8s |
| 5 | terraform client: `ErrJobAlreadyExists` | ✅ | `client.go`: 409 Conflict → `ErrJobAlreadyExists`; `job_resource.go`: при конфликте создания — читаем существующий job вместо ошибки |
---
## 2026-03-18 — Python runtime str→text/plain
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | `runtimes/python3.11/server.py` | ✅ | Если функция вернула `str` → `Content-Type: text/plain`, тело без `json.dumps`. Dict/list остаются `application/json` |
| 2 | `internal/builder/context.go` | ✅ | Python runtime базовый образ → `v0.1.3` |
| 3 | Образ `naeel/sless-runtime-python3.11:v0.1.3` | ✅ | Собран и задеплоен |
---
## 2026-03-18 — funcs: глобальный Go сервис + ветка web-console
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Python runtime str→text/plain | ✅ | `runtimes/python3.11/server.py`: если функция возвращает `str` → `Content-Type: text/plain`. Образ `naeel/sless-runtime-python3.11:v0.1.3` |
| 2 | funcs_list.py: plain text вывод | ✅ | Переписан с JSON на plain text с `─` разделителями, фильтр `SLESS_EXCLUDE` |
| 3 | Оператор v0.1.33 | ✅ | context.go: python runtime → v0.1.3; `internal/builder/context.go` обновлён |
| 4 | `funcs` как глобальный Go сервис | ✅ | `services/funcs/main.go` — standalone Go HTTP сервер. Деплоится ОДИН РАЗ в namespace `sless`, работает для ВСЕХ пользователей. Образ `naeel/sless-funcs-service:v0.1.3` |
| 5 | URL без токена | ✅ | `https://sless.kube5s.ru/funcs/<namespace>` — открывается в браузере без токена. `SLESS_SERVICE_TOKEN` задан через `kubectl set env` |
| 6 | Удалить funcs из terraform | ✅ | `examples/POSTGRES/resources.tf`: удалены `sless_function.funcs_list`, `sless_trigger.funcs_list_http`, `output "funcs_url"`, `local.user_namespace`. `terraform apply` — 2 destroyed |
| 7 | Ветка feat/web-console | ✅ | Создана от `feat/harbor-integration` на remote, запушена |
| 8 | Документация архитектуры хранения кода | ✅ | См. `doc/decisions/log.md` раздел "Хранение кода функций и web-консоль" |
| 9 | Коммиты | ✅ | `e8cd62e` (funcs Go service), `38bb494` (v0.1.3 — /funcs/namespace без токена) |
### Текущие URL функций
```
https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae # список функций (без токена)
https://sless.kube5s.ru/funcs?token=<jwt> # то же, через токен
https://sless.kube5s.ru/fn/sless-ffd1f598c169b0ae/pg-info
https://sless.kube5s.ru/fn/sless-ffd1f598c169b0ae/pg-table-reader
```
### Образы
| Образ | Версия | Что изменилось |
|-------|--------|----------------|
| `naeel/sless-operator` | v0.1.33 | created_at, last_built_at в API; python runtime v0.1.3 |
| `naeel/sless-runtime-python3.11` | v0.1.3 | str → text/plain в server.py |
| `naeel/sless-funcs-service` | v0.1.3 | /funcs/<namespace> без токена, /health probe |
---
## 2026-03-18 — Следующие шаги: web-консоль (ветка feat/web-console)
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Оператор: `GET /v1/namespaces/{ns}/functions/{name}/source` | ✅ | Реализовано — `bf9f073` |
| 2 | Оператор: `PATCH /v1/namespaces/{ns}/triggers/{name}` | ✅ | Существовал — проверено, поддерживает `enabled` |
| 3 | `sless-funcs-service`: HTML страница | ✅ | `Accept: text/html` → HTML консоль. Аккордеоны + highlight.js + кнопки ▶/■ |
| 4 | Bump оператора до v0.1.34 | ✅ | Задеплоен |
| 5 | Bump funcs-service до v0.2.0 | ✅ | Задеплоен |
---
## 2026-03-18 12:00 — DNS инцидент устранён, POSTGRES E2E PASS
## 2026-03-18 — DNS инцидент: sless-api.kube5s.ru → мёртвый кластер
| # | Задача | Статус | Заметки |
|---|--------|--------|----------|
| 1 | Диагностика TLS handshake timeout при `terraform apply` POSTGRES | ✅ | `sless-api.kube5s.ru` → `5.172.178.182` (старый кластер). Порт 443 принимал TCP, но TLS не завершался — Go HTTP клиент висел бесконечно |
| 2 | Подтверждение нового кластера | ✅ | `kubectl get nodes` в контексте `tazetdinovn@gmail.com@naeel-test-3` → 3 ноды Ready. Ingress IP `185.247.187.147`. DNS `sless.kube5s.ru` уже указывал на него |
| 3 | Обновить Ingress в кластере | ✅ | `kubectl patch ingress sless-operator -n sless`: host + TLS → `sless.kube5s.ru`, secretName → `sless-operator-tls-sless` |
| 4 | Обновить ConfigMap оператора | ✅ | `EXTERNAL_URL: https://sless.kube5s.ru` (было `https://sless-api.kube5s.ru`) |
| 5 | Let's Encrypt сертификат | ✅ | `certificate/sless-operator-tls-sless` READY=True, order valid — выдан сразу после смены ingress host (ACME HTTP-01 challenge на новом IP прошёл) |
| 6 | Заменить `sless-api.kube5s.ru` → `sless.kube5s.ru` во всех examples + deployments | ✅ | `POSTGRES/`, `hello-go/`, `hello-node/`, `simple-node/`, `simple-python/`, `notes-python/`, `pg-list-python/`, `demo-managed-functions/`, `README.md`, `deployments/k8s/operator.yaml` |
| 7 | POSTGRES `terraform apply` с функцией и job | ✅ PASS | `sless_function` `pg-create-table-runner` — создан за 55s. `sless_job` `pg-create-table-job-main-v13` — создан за 20s. `Apply complete! Resources: 2 added` |
| 8 | Коммит и push | ✅ | `cca3a8c` — `fix: migrate sless endpoint from sless-api.kube5s.ru to sless.kube5s.ru` |
---
## 2026-03-17 — E2E тесты всех примеров на тест-стенде
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Исправить `nubes_endpoint` во всех examples (prod→test) | ✅ | `deck-api.ngcloud.ru` → `deck-api-test.ngcloud.ru/api/v1` в hello-node, hello-go, simple-node, notes-python, pg-list-python, demo-managed-functions |
| 2 | Исправить `sless_endpoint` в demo-managed-functions | ✅ | `185.247.187.147.nip.io` → `https://sless-api.kube5s.ru` |
| 3 | Postgres для тестов | ✅ | `postgres.sless.svc.cluster.local:5432`, user=`sless`, pass=`sless-pg-password`, db=`sless`. Уже запущен в кластере |
| 4 | `hello-node` E2E | ✅ PASS | init → apply (4 ресурса) → modify (memory_mb 128→256, env_var) → apply → destroy |
| 5 | `simple-python` E2E | ✅ PASS | init → apply → modify (memory_mb 64→96) → apply → destroy. `job_result = {"time": "..."}` |
| 6 | `simple-node` E2E | ✅ PASS | Аналогично simple-python но nodejs20 |
| 7 | `notes-python` E2E | ✅ PASS | 7 ресурсов, PostgreSQL init job: `{"ok": true, "executed": 1}`, destroy complete |
| 8 | Коммит на remote | ✅ | `a768454` в `/home/naeel/terra/sless/examples` |
| 9 | POSTGRES example | 🔄 | Следующий шаг — вернуться к POSTGRES после прохождения всех тестов |
---
## 2026-03-17 — v0.1.31: compile fix + POSTGRES example end-to-end
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Баг 3: `PodLogOptions{Stdout/Stderr}` compile error | ✅ | Убраны несуществующие поля. `getJobPodOutput` в `functionjob_controller.go` |
| 2 | Сборка `naeel/sless-operator:v0.1.31` | ✅ | Собрано на `naeel@5.172.178.213`, запушено в Docker Hub |
| 3 | Деплой v0.1.31 в кластер | ✅ | `kubectl rollout status` → success. Pod `sless-operator-545556c59d-n8qv9` |
| 4 | `simple-python` — сквозной тест на тест-стенде | ✅ | `terraform apply` на remote: `Apply complete! Resources: 3 added`. `job_result = {"time": "..."}` |
| 5 | Найдена главная причина падения POSTGRES | ✅ | `nubes_endpoint` в `provider "sless"` указывал на **прод** `deck-api.ngcloud.ru` |
| 6 | Исправлен `nubes_endpoint` в POSTGRES/main.tf | ✅ | Теперь `deck-api-test.ngcloud.ru/api/v1` |
| 7 | Исправлен `nubes_endpoint` в simple-python/main.tf | ✅ | Аналогично |
| 8 | End-to-end `terraform apply` для POSTGRES | 🔄 | Следующий шаг: синхронизировать на remote и запустить `terraform apply` |
---
## 2026-03-17 — POSTGRES example: внешний managed PG, FunctionJob и реальные блокеры
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Перевод `examples/POSTGRES` на `PGSSLMODE=require` | ✅ | Для managed PostgreSQL `allow_no_s_s_l = false`; `disable` был некорректен |
| 2 | Подтверждение root-cause `password authentication failed` | ✅ | Из runtime namespace поднят диагностический pod того же image: `psycopg2.OperationalError: FATAL: password authentication failed for user "u-user0"` |
| 3 | Проверка источника пароля | ✅ | `nubes_postgres.npg.vault_secrets["users"]` не совпадает с фактическим паролем пользователя в кластере managed PostgreSQL; authoritative source сейчас — k8s secret `u-user0.postgresqlk8s.credentials.postgresql.acid.zalan.do` |
| 4 | Попытка читать пароль через `hashicorp/external` | ❌ | В текущем окружении доступен только кастомный registry; `registry.terraform.io/hashicorp/external` не устанавливается |
| 5 | One-command `terraform apply` для `examples/POSTGRES` | 🔄 | Остаточный баг локализован в связке credential-source + FunctionJob observability. После переключения на актуальный пароль `sless_job` всё ещё падает, но актуальные pod logs реального job теряются из-за TTL/слабой наблюдаемости |
| 6 | Необходимый следующий технический шаг | 🔄 | Либо добавить в проект доступный data-source/провайдер для чтения k8s secret внутри apply, либо починить источник пароля в nubes-потоке, чтобы `vault_secrets` и реальный Postgres user password совпадали |
## 2026-03-17 — Fix: `examples/POSTGRES` / `sless_job` 409 + диагностика FunctionJob
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Идемпотентность `sless_job` при `409 job already exists` | ✅ | `terraform/provider/internal/client/client.go`, `terraform/provider/internal/resources/job_resource.go`: добавлен typed-error `ErrJobAlreadyExists`, в Create при 409 читается существующий Job и продолжается ожидание/синхронизация вместо немедленного падения |
| 2 | Диагностика падений FunctionJob | ✅ | `controllers/functionjob_controller.go`: labels добавлены в `PodTemplate`, fail-message теперь включает output pod-а (обрезка до 2000), fallback-команда логов по `job-name` |
| 3 | Root-cause для `examples/POSTGRES` | ✅ | Рабочие `examples/notes-python` используют внутренний DSN (`postgres.sless.svc`), а `examples/POSTGRES` работает через managed Nubes Postgres + динамические креды; критичный UX-дефект был в повторном Create после failed apply |
## Статусы: ✅ готово | 🔄 в процессе | ⏳ не начато
@@ -230,3 +432,96 @@
| 6 | `replicas` field в FunctionSpec | Низкий (v1.1) |
| 7 | RabbitMQ event triggers | Низкий (v2) |
| 8 | Метрики → Victoria Metrics | Низкий |
---
## 2026-03-11 — Harbor integration + Go 1.23 runtime (v0.1.24–v0.1.26)
### Что сделано
| # | Компонент | Изменение |
|---|-----------|-----------|
| 1 | `internal/harbor/` | Harbor-клиент: `EnsureProject` создаёт проект перед push образа |
| 2 | `internal/builder/builder.go` | Поддержка `HarborClient`, push в `pearlharbor.registryk8s.services.ngcloud.ru` |
| 3 | `runtimes/go1.23/` | Новый Go 1.23 runtime: `server.go` + `runner.go` + Dockerfile |
| 4 | `internal/builder/context.go` | Go 1.23 в switch; `COPY . /app/handler/` вместо `COPY handler/` |
| 5 | `api/v1alpha1/function_types.go` | `go1.23` в enum поддерживаемых рантаймов |
| 6 | `terraform/provider/` | v0.1.14: `go1.23` в `stringvalidator.OneOf` |
| 7 | `deployments/k8s/operator.yaml` | v0.1.26 |
| 8 | `examples/hello-go/` | Новый пример: HTTP + job на Go 1.23 |
| 9 | `examples/hello-go/code/greeting.go` | Переименован из handler.go, функция `buildGreeting(guestName)` |
| 10 | `examples/pg-list-python/` | Новый пример: Python + PostgreSQL, только HTTP, без job |
### Docker Hub auth на 192.168.1.220
- Токен: `dckr_pat_aElN35tdXMBrtOAPraV5Fi0o58s` (сохранён в `secrets/dockerhub.env`, chmod 600)
- `docker login -u naeel --password-stdin` выполнен на remote machine
### Исправленные баги
- kaniko `COPY handler/ /app/handler/` — файлы в tar-контексте без prefix → исправлено на `COPY .`
- `Decimal` из psycopg2 не сериализуется → `float(row[2])` в `catalog.py`
- Formatter добавил дублирующий `package code` в `greeting.go` — удалён вручную
- CRD в кластере имел только `go1.21` в enum → `kubectl apply` обновлён CRD
- Provider v0.1.13 имел только `go1.21` → пересобран v0.1.14
### Git HEAD
```
d6a2e22 fix: восстановлен requirements.txt после теста
5c6a373 docs: переработан examples/README.md + комментарии function.tf
2ca3137 feat: пример pg-list-python
c4559dd fix: go1.23 build context
78d11ae refactor: rename handler.go→greeting.go
a709b38 feat: go1.23 runtime support
babd8e6 feat: harbor integration
```
---
## 2026-03-11 — UX улучшения: build logs + JSON для всех HTTP методов (v0.1.27–v0.1.28)
### Проблемы до исправления
| Проблема | Симптом |
|---|---|
| Ошибка сборки без деталей | `terraform apply` показывал только `function build failed: build job failed` без причины |
| HTML 501 при DELETE/HEAD/PUT | Python runtime `http.server` не обрабатывал эти методы → HTML-ответ вместо JSON |
### Что изменили
#### `controllers/function_controller.go`
- `FunctionReconciler` получил поле `KubeClient kubernetes.Interface`
- Добавлен `getBuildPodLogs(ctx, kube, namespace, jobName)` — читает логи kaniko-пода (последние 50 строк)
- При сбое сборки (`case "failed"`) вызывает `getBuildPodLogs` и включает вывод в `Function.Status.Message`
- Провайдер пробрасывает `Message` в ошибку `terraform apply` — разработчик видит точную причину
#### `runtimes/python3.11/server.py`
- Выделен `_handle_with_body()` — общий обработчик для методов с телом
- `do_PUT`, `do_DELETE`, `do_PATCH` = `_handle_with_body` — вызывают функцию пользователя
- `do_HEAD` — отдаёт заголовки без тела (HTTP-стандарт; тело вычисляется но не отправляется)
- `send_error()` переопределён → JSON `{"error": "...", "code": N}` вместо HTML 501
- Runtime пересобран: `naeel/sless-runtime-python3.11:v0.1.2`
#### `main.go`
- `KubeClient: kubernetes.NewForConfigOrDie(mgr.GetConfig())` передаётся в `FunctionReconciler`
#### `internal/builder/context.go`
- python3.11 runtime: `v0.1.1` → `v0.1.2`
### Версии после исправления
- Оператор: `naeel/sless-operator:v0.1.28`
- Python runtime: `naeel/sless-runtime-python3.11:v0.1.2`
- Provider: `terra.k8c.ru/naeel/sless v0.1.14` (без изменений)
### Результат тестирования
| Тест | До | После |
|---|---|---|
| `DELETE /fn/...` | HTML 501 | ✅ JSON 200, функция вызывается |
| `HEAD /fn/...` | HTML 501 | ✅ HTTP 200, Content-Type: application/json |
| `PUT /fn/...` | HTML 501 | ✅ JSON 200, функция вызывается |
| Невалидный `requirements.txt` | `build job failed` | ✅ Полный вывод pip включая строку с ошибкой |
### Git коммиты
```
6de90ac chore: python runtime v0.1.2 + operator v0.1.28
3372cb1 fix: build logs in status + JSON for all HTTP methods in python runtime
```
+41
View File
@@ -0,0 +1,41 @@
# Created: 2026-03-11
# Purpose: ignore generated artifacts for the `examples` repository
# Terraform
.terraform/
*.tfstate
*.tfstate.*
.terraform.lock.hcl
crash.log
# Terraform plans / backups
*.tfplan
*.backup
*.bak
# Provider plugins / caches
.terraform.d/
*.tfvars
# Archives and build artifacts
*.zip
dist/
build/
# Node / Python
node_modules/
__pycache__/
*.pyc
venv/
.venv/
# Editor / OS files
.DS_Store
*.swp
*.swo
# Environment files
.env
*.local
*.log
-143
View File
@@ -1,143 +0,0 @@
# Bug Report: HTTP route is not removed after Terraform destroy
## Summary
При удалении примера `hello-node` через Terraform команда `terraform destroy` завершается успешно, но публичный HTTP endpoint не удаляется.
Фактическое поведение после `destroy` такое:
1. Сразу после удаления endpoint ещё некоторое время отвечает `HTTP 200` и возвращает корректный ответ функции.
2. Затем backend функции действительно исчезает, но публичный маршрут остаётся опубликованным и начинает отвечать `HTTP 502 function unreachable`.
3. Даже через 120 секунд endpoint не исчезает.
Это выглядит как баг cleanup в platform/backend/provider lifecycle для HTTP trigger/route.
## Affected Example
- Example: `hello-node`
- Terraform files: `hello-node/main.tf`, `hello-node/http.tf`, `hello-node/job.tf`
- Public URL: `https://sless-api.kube5s.ru/fn/default/hello-http`
- Function name: `hello-http`
- Trigger name: `hello-http-trigger`
## Reproduction
Использовался репозиторий examples и скрипт:
- Script: `./run_terraform_examples.sh`
Шаги воспроизведения:
1. Выполнить `terraform init` в `hello-node`
2. Выполнить `terraform apply`
3. Убедиться, что endpoint живой
4. Выполнить `terraform destroy`
5. Проверять публичный URL после destroy
Логика проверки встроена в `run_terraform_examples.sh`:
1. После `apply` endpoint обязан отвечать `200`
2. После `destroy` endpoint должен исчезнуть
3. Скрипт ждёт до 120 секунд и перепроверяет endpoint каждые 5 секунд
## Expected Result
После успешного `terraform destroy`:
1. Публичный URL должен перестать существовать
2. Запрос на URL должен вернуть `404` или другой явный признак отсутствия маршрута
3. Provider не должен возвращать успешный destroy раньше, чем cleanup HTTP route завершён
## Actual Result
После успешного `terraform destroy`:
1. Terraform сообщает `Destroy complete! Resources: 4 destroyed.`
2. Endpoint `https://sless-api.kube5s.ru/fn/default/hello-http` продолжает отвечать `200`
3. Через некоторое время тот же endpoint начинает отвечать `502`
4. Тело ответа на `502`:
```json
{"error":"function unreachable: Post \"http://hello-http.sless-fn-default.svc.cluster.local:8080\": dial tcp 10.106.128.167:8080: connect: operation not permitted"}
```
Это означает:
1. внешний HTTP маршрут всё ещё существует;
2. запрос по нему всё ещё направляется внутрь платформы;
3. backend функции уже удалён или недоступен;
4. cleanup маршрута не завершён.
## Timeline From Real Run
Подтверждённая последовательность из фактического прогона:
1. `terraform destroy` завершился успешно
2. первые проверки после destroy возвращали `HTTP 200`
3. затем проверки начали возвращать `HTTP 502 function unreachable`
4. в течение всех 24 проверок по 5 секунд endpoint не исчез
5. итоговое время ожидания: 120 секунд
Итоговый summary из скрипта:
```text
ERROR SUMMARY
example: hello-node
step: endpoint cleanup after clean destroy
reason: route cleanup bug: public endpoint still exists but backend is already gone (HTTP 502 function unreachable); endpoint was still published after 120s
```
## Why This Is A Real Platform Bug
Это не похоже на проблему тестового скрипта или Terraform CLI по следующим причинам:
1. `terraform destroy` завершается без ошибки
2. state Terraform очищается как ожидалось
3. сначала endpoint отвечает `200`, значит маршрут реально жив после destroy
4. потом endpoint отвечает `502 function unreachable`, значит backend уже исчез, но route ещё остался
5. скрипт ждёт 120 секунд, то есть это не мгновенная eventual consistency на 1-2 секунды
Иными словами: удаление backend и удаление публичного маршрута расходятся по времени, а route cleanup либо не выполняется, либо не дожидается завершения.
## Most Likely Broken Layer
Наиболее вероятные точки проблемы:
1. API/backend destroy trigger возвращает success до фактического удаления HTTP route
2. Controller удаляет function workload, но не удаляет route/ingress/virtualservice/gateway mapping
3. Удаление route запускается асинхронно, но его результат не awaited
4. В системе остаётся запись маршрута на имя функции, хотя service/backend уже удалён
## What To Check In The Development Repo
Нужно проверить destroy flow именно для HTTP trigger:
1. Удаляется ли объект trigger только в metadata/storage или реально удаляется и внешний маршрут
2. Какие Kubernetes/ingress объекты создаются для HTTP trigger и все ли они удаляются
3. Есть ли race condition между удалением function/service и удалением route
4. Не возвращает ли provider success раньше, чем backend подтверждает полное удаление маршрута
5. Есть ли финальный polling/wait на исчезновение route перед возвратом успешного destroy
Если архитектура использует отдельные сущности route/service/function, то destroy должен идти в таком порядке:
1. disable/remove public routing
2. дождаться, что endpoint больше не публикуется снаружи
3. удалить backend/service/workload
4. завершить destroy success
Сейчас по фактическому поведению порядок либо обратный, либо неполный.
## Minimal Acceptance Criteria For Fix
Исправление можно считать рабочим, если после `terraform destroy` для `hello-node` выполняются все условия:
1. URL `https://sless-api.kube5s.ru/fn/default/hello-http` перестаёт отвечать как живой маршрут
2. URL не возвращает `502 function unreachable`
3. URL исчезает в разумное время после destroy
4. `./run_terraform_examples.sh` проходит шаг `endpoint cleanup after clean destroy`
## Current Status
На данный момент массовый прогон examples корректно останавливается на `hello-node`, потому что это первый воспроизводимый failure.
Дальше прогонять остальные примеры без исправления destroy cleanup смысла нет: тест уже доказал platform bug на базовом HTTP сценарии.
@@ -0,0 +1,94 @@
# 2026-03-18 (обновлено: plain text вывод; фильтрация SLESS_EXCLUDE)
# funcs_list.py — HTTP-функция: список пользовательских функций, человекочитаемый plain text.
# Вызывает внутренний REST API оператора (ClusterIP, без TLS).
# Возвращает str → python runtime отдаёт text/plain напрямую без json.dumps.
#
# Env vars:
# SLESS_API_URL — URL оператора (http://sless-operator.sless.svc.cluster.local:9090)
# SLESS_NAMESPACE — namespace пользователя (sless-{hex16})
# SLESS_TOKEN — JWT токен для /v1/ API
# SLESS_EXTERNAL_URL — публичный базовый URL (https://sless.kube5s.ru)
# SLESS_EXCLUDE — comma-separated имена функций, которые не показывать
import os
import requests
SEP = "─" * 52
def _comment(fn, http_trigs, cron_trigs):
phase = fn.get("phase", "?")
runtime = fn.get("runtime", "?")
if http_trigs:
active = "активна" if http_trigs[0].get("active") else "неактивна"
return f"HTTP endpoint ({runtime}) — {phase}, {active}"
elif cron_trigs:
schedule = cron_trigs[0].get("schedule", "?")
active = "активна" if cron_trigs[0].get("active") else "неактивна"
return f"Cron '{schedule}' ({runtime}) — {phase}, {active}"
else:
return f"Job/runner без триггера ({runtime}) — {phase}"
def list_all(event):
api_url = os.environ["SLESS_API_URL"].rstrip("/")
namespace = os.environ["SLESS_NAMESPACE"]
token = os.environ["SLESS_TOKEN"]
ext_url = os.environ.get("SLESS_EXTERNAL_URL", "").rstrip("/")
exclude = {n.strip() for n in os.environ.get("SLESS_EXCLUDE", "").split(",") if n.strip()}
headers = {"Authorization": f"Bearer {token}"}
fns = requests.get(f"{api_url}/v1/namespaces/{namespace}/functions", headers=headers, timeout=10)
trs = requests.get(f"{api_url}/v1/namespaces/{namespace}/triggers", headers=headers, timeout=10)
fns.raise_for_status()
trs.raise_for_status()
trig_idx = {}
for tr in trs.json():
fn_name = tr.get("function") or tr.get("functionRef")
if fn_name:
trig_idx.setdefault(fn_name, []).append(tr)
items = []
for fn in fns.json():
name = fn["name"]
if name in exclude:
continue
http_t = [t for t in trig_idx.get(name, []) if t.get("type") == "http"]
cron_t = [t for t in trig_idx.get(name, []) if t.get("type") == "cron"]
is_active = any(t.get("enabled", True) and t.get("active", False) for t in trig_idx.get(name, []))
items.append((fn, http_t, cron_t, is_active))
# Сортировка: активные вверх, затем по имени
items.sort(key=lambda x: (not x[3], x[0]["name"]))
lines = []
for fn, http_t, cron_t, is_active in items:
name = fn["name"]
lines.append(SEP)
lines.append(f" {_comment(fn, http_t, cron_t)}")
lines.append(f" name: {name}")
lines.append(f" runtime: {fn.get('runtime', '?')}")
lines.append(f" phase: {fn.get('phase', '?')}")
lines.append(f" active: {'да' if is_active else 'нет'}")
if http_t:
url = f"{ext_url}/fn/{namespace}/{name}" if ext_url else http_t[0].get("url", "")
lines.append(f" url: {url}")
if cron_t:
lines.append(f" cron: {cron_t[0].get('schedule', '?')}")
if fn.get("created_at"):
lines.append(f" created: {fn['created_at']}")
if fn.get("last_built_at"):
lines.append(f" built: {fn['last_built_at']}")
if fn.get("message"):
lines.append(f" message: {fn['message']}")
lines.append(SEP)
lines.append(f" namespace: {namespace} | total: {len(items)}")
lines.append(SEP)
# Возвращаем str — python runtime отдаст text/plain напрямую
return "\n".join(lines) + "\n"
@@ -0,0 +1 @@
requests==2.31.0
@@ -0,0 +1,8 @@
{
"name": "pg-info",
"version": "1.0.0",
"description": "sless nodejs20 function: pg version + table info",
"dependencies": {
"pg": "8.11.0"
}
}
+43
View File
@@ -0,0 +1,43 @@
// 2026-03-18
// pg_info.js — NodeJS-функция: проверка работы JS runtime + чтение мета-данных БД.
// Подключается к PostgreSQL через пакет pg, возвращает версию сервера и счётчик строк.
// Демонстрирует: nodejs20 runtime, npm-зависимость (package.json), PG из JS.
//
// ENV (те же что у python-функций):
// PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, PGSSLMODE
//
// Entrypoint: pg_info.info
'use strict';
const { Client } = require('pg');
exports.info = async (event) => {
const client = new Client({
host: process.env.PGHOST,
port: parseInt(process.env.PGPORT || '5432'),
database: process.env.PGDATABASE,
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
// pg-пакет требует явного ssl-объекта; rejectUnauthorized: false — т.к.
// self-signed cert на nubes managed PG, но канал всё равно шифруется.
ssl: process.env.PGSSLMODE === 'require' ? { rejectUnauthorized: false } : false,
});
await client.connect();
try {
const [versionRes, countRes] = await Promise.all([
client.query('SELECT version() AS v'),
client.query('SELECT COUNT(*) AS cnt FROM terraform_demo_table'),
]);
return {
runtime: 'nodejs20',
node_version: process.version,
pg_version: versionRes.rows[0].v,
table_rows: parseInt(countRes.rows[0].cnt, 10),
};
} finally {
await client.end();
}
};
@@ -0,0 +1,3 @@
# 2026-03-17 00:00
# requirements.txt — зависимости для функции запуска SQL.
psycopg2-binary==2.9.9
@@ -0,0 +1,39 @@
# 2026-03-17 00:00
# sql_runner.py — функция для выполнения SQL-операторов из входного события.
import os
import psycopg2
def run_sql(event):
# Выполняет список SQL-операторов в одной транзакции для атомарной инициализации схемы.
# Параметры подключения передаются раздельно, чтобы избежать ошибок парсинга DSN при спецсимволах.
pg_host = os.environ["PGHOST"]
pg_port = os.environ.get("PGPORT", "5432")
pg_database = os.environ["PGDATABASE"]
pg_user = os.environ["PGUSER"]
pg_password = os.environ["PGPASSWORD"]
pg_sslmode = os.environ.get("PGSSLMODE", "require")
statements = event.get("statements", [])
if not statements:
return {"error": "no statements provided"}
connection = psycopg2.connect(
host=pg_host,
port=pg_port,
dbname=pg_database,
user=pg_user,
password=pg_password,
sslmode=pg_sslmode,
)
try:
cursor = connection.cursor()
for statement in statements:
cursor.execute(statement)
connection.commit()
return {"ok": True, "executed": len(statements)}
except Exception as error:
connection.rollback()
return {"error": str(error)}
finally:
connection.close()
@@ -0,0 +1 @@
psycopg2-binary==2.9.9
+130
View File
@@ -0,0 +1,130 @@
# 2026-03-19
# table_rw.py — чтение и запись строк в terraform_demo_table.
# Два entrypoint в одном файле: list_rows (JSON API) и add_row (HTML-страница + POST-обработчик).
# ENV: PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, PGSSLMODE
import os
import json
import psycopg2
import psycopg2.extras
def _connect():
return psycopg2.connect(
host=os.environ["PGHOST"],
port=os.environ.get("PGPORT", "5432"),
dbname=os.environ["PGDATABASE"],
user=os.environ["PGUSER"],
password=os.environ["PGPASSWORD"],
sslmode=os.environ.get("PGSSLMODE", "require"),
)
def list_rows(event):
# Возвращает все строки terraform_demo_table, отсортированные по убыванию created_at.
conn = _connect()
try:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC"
)
rows = [dict(r) for r in cur.fetchall()]
return {"rows": rows, "count": len(rows)}
finally:
conn.close()
def _render_page(rows, message=""):
# HTML-страница с формой ввода и таблицей строк.
# message — статус последней операции (успех / ошибка).
rows_html = "".join(
f"<tr><td>{r['id']}</td><td>{r['title']}</td><td>{r['created_at']}</td></tr>"
for r in rows
)
msg_html = f'<p class="msg">{message}</p>' if message else ""
return f"""<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>pg-table-writer</title>
<style>
body {{ font-family: sans-serif; max-width: 700px; margin: 40px auto; background: #111; color: #eee; }}
h1 {{ color: #7dd3fc; }}
form {{ display: flex; gap: 8px; margin-bottom: 24px; }}
input[type=text] {{ flex: 1; padding: 8px 12px; border-radius: 6px; border: 1px solid #444; background: #1e1e1e; color: #eee; font-size: 15px; }}
button {{ padding: 8px 18px; background: #2563eb; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 15px; }}
button:hover {{ background: #1d4ed8; }}
table {{ width: 100%; border-collapse: collapse; }}
th, td {{ padding: 8px 10px; border-bottom: 1px solid #333; text-align: left; }}
th {{ color: #7dd3fc; }}
.msg {{ color: #4ade80; margin-bottom: 12px; }}
</style>
</head>
<body>
<h1>pg-table-writer</h1>
<form method="POST">
<input type="text" name="title" placeholder="Введите строку..." autofocus required>
<button type="submit">Добавить</button>
</form>
{msg_html}
<table>
<thead><tr><th>#</th><th>title</th><th>created_at</th></tr></thead>
<tbody>{rows_html}</tbody>
</table>
</body>
</html>"""
def add_row(event):
# GET → HTML-страница с формой и списком строк.
# POST → вставляет строку из form-поля title или JSON-поля title,
# затем возвращает обновлённую HTML-страницу.
# POST с Content-Type: application/json (curl/API) → возвращает JSON.
method = event.get("_method", "GET")
if method == "GET":
conn = _connect()
try:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC")
rows = [dict(r) for r in cur.fetchall()]
finally:
conn.close()
return _render_page(rows)
# POST — вставка строки
# Поле title приходит либо из JSON-тела, либо из application/x-www-form-urlencoded.
# Сервер уже распарсил JSON в event; form-данные приходят как event["body"] = "title=...".
title = event.get("title", "").strip()
if not title:
# Попытка распарсить form-encoded body (браузерная форма)
body = event.get("body", "")
if body.startswith("title="):
from urllib.parse import unquote_plus
title = unquote_plus(body[len("title="):].split("&")[0]).strip()
if not title:
return {"ok": False, "error": "title is required"}
conn = _connect()
try:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"INSERT INTO terraform_demo_table (title) VALUES (%s) RETURNING id, title, created_at::text",
(title,),
)
row = dict(cur.fetchone())
conn.commit()
# Если запрос из браузера (form POST) — возвращаем обновлённую страницу.
# Если из curl/API — возвращаем JSON.
accept = event.get("_accept", "")
if "application/json" in accept:
return {"ok": True, "row": row}
# Перечитываем все строки для обновлённой страницы
cur.execute("SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC")
rows = [dict(r) for r in cur.fetchall()]
return _render_page(rows, message=f"Добавлено: «{row['title']}»")
finally:
conn.close()
+129
View File
@@ -0,0 +1,129 @@
# 2026-03-18 (обновлено: фильтрация SLESS_EXCLUDE, читаемый вывод через "#"-ключ)
# funcs_list.py — HTTP-функция: список всех пользовательских функций с их статусами.
# Вызывает внутренний REST API оператора (ClusterIP, без TLS).
# Объединяет данные функций и триггеров в один ответ; скрывает служебные функции.
#
# Env vars:
# SLESS_API_URL — URL оператора (http://sless-operator.sless.svc.cluster.local:9090)
# SLESS_NAMESPACE — namespace пользователя (sless-{hex16})
# SLESS_TOKEN — JWT токен для /v1/ API
# SLESS_EXTERNAL_URL — публичный базовый URL (https://sless.kube5s.ru), для корректных ссылок
# SLESS_EXCLUDE — comma-separated имена функций, которые не надо показывать
# Пример: "funcs,event-writer,event-monitor,event-cleaner"
#
# Формат вывода: JSON-объект, где каждая функция содержит поле "#" — краткий комментарий.
# При pretty-print (python3 -m json.tool) выглядит как читаемый список с аннотациями.
import os
import requests
def _short_comment(fn, http_triggers, cron_triggers):
"""Генерирует однострочный комментарий-описание функции по её метаданным."""
phase = fn.get("phase", "")
runtime = fn.get("runtime", "")
if http_triggers:
active_str = "активна" if http_triggers[0].get("active") else "неактивна"
return f"HTTP endpoint ({runtime}) — {phase}, {active_str}"
elif cron_triggers:
schedule = cron_triggers[0].get("schedule", "?")
active_str = "активна" if cron_triggers[0].get("active") else "неактивна"
return f"Cron '{schedule}' ({runtime}) — {phase}, {active_str}"
else:
return f"Job/runner без триггера ({runtime}) — {phase}"
def list_all(event):
api_url = os.environ["SLESS_API_URL"].rstrip("/")
namespace = os.environ["SLESS_NAMESPACE"]
token = os.environ["SLESS_TOKEN"]
ext_url = os.environ.get("SLESS_EXTERNAL_URL", "").rstrip("/")
# Имена функций, которые не должны присутствовать в выводе.
# Включает саму себя ("funcs") и служебные функции других примеров.
exclude = {
n.strip()
for n in os.environ.get("SLESS_EXCLUDE", "").split(",")
if n.strip()
}
headers = {"Authorization": f"Bearer {token}"}
fns_resp = requests.get(
f"{api_url}/v1/namespaces/{namespace}/functions",
headers=headers,
timeout=10,
)
fns_resp.raise_for_status()
trs_resp = requests.get(
f"{api_url}/v1/namespaces/{namespace}/triggers",
headers=headers,
timeout=10,
)
trs_resp.raise_for_status()
# Индекс триггеров по имени функции
triggers_by_fn = {}
for tr in trs_resp.json():
fn_name = tr.get("function") or tr.get("functionRef")
if fn_name:
triggers_by_fn.setdefault(fn_name, []).append(tr)
result = []
for fn in fns_resp.json():
name = fn["name"]
if name in exclude:
continue
http_triggers = [
t for t in triggers_by_fn.get(name, []) if t.get("type") == "http"
]
cron_triggers = [
t for t in triggers_by_fn.get(name, []) if t.get("type") == "cron"
]
is_active = any(
t.get("enabled", True) and t.get("active", False)
for t in triggers_by_fn.get(name, [])
)
entry = {
# "#" — первый ключ: служит визуальным комментарием при pretty-print
"#": _short_comment(fn, http_triggers, cron_triggers),
"name": name,
"runtime": fn.get("runtime"),
"phase": fn.get("phase"),
"active": is_active,
}
# URL вычисляем из SLESS_EXTERNAL_URL если задан — state может хранить старый домен
if http_triggers:
if ext_url:
entry["url"] = f"{ext_url}/fn/{namespace}/{name}"
else:
entry["url"] = http_triggers[0].get("url", "")
if cron_triggers:
entry["cron"] = cron_triggers[0].get("schedule", "")
if fn.get("message"):
entry["message"] = fn["message"]
# created_at и last_built_at — доступны после обновления оператора до v0.1.32+
if fn.get("created_at"):
entry["created_at"] = fn["created_at"]
if fn.get("last_built_at"):
entry["last_built_at"] = fn["last_built_at"]
result.append(entry)
# Сортировка: активные вверх, затем по имени
result.sort(key=lambda f: (not f["active"], f["name"]))
return {
"namespace": namespace,
"count": len(result),
"functions": result,
}
+73
View File
@@ -0,0 +1,73 @@
# resource "nubes_lucee" "app1" {
# # Lucee-приложение, зависит от Postgres
# resource_name = "lucy_teststand_0"
# # resource_realm = "k8s-3.ext.nubes.ru"
# resource_realm = nubes_postgres.db2.resource_realm
# # resource_realm = "k8s-4-sandbox-nubes-ru"
# domain = "web-test-stand"
# git_path = "https://gitea-naeel.giteak8s.services.ngcloud.ru/naeel/testlucee"
# json_env = jsonencode({
# # 🔗 Настройки Data Source 'testds' для Lucee (Application.cfc)
# testds_class = "org.postgresql.Driver" # 📂 Драйвер БД
# testds_bundleName = "org.postgresql.jdbc" # 📦 Имя бандла JDBC
# testds_bundleVersion = "42.6.0" # 🔢 Версия драйвера
# testds_connectionString = "jdbc:postgresql://${nubes_postgres.db2.state_out_flat["internalConnect.master"]}:5432/postgres?sslmode=require" # 🚀 Строка подключения
# testds_username = nubes_postgres_user.db2_user.username # 👤 Логин
# testds_password = jsondecode(nubes_postgres.db2.vault_secrets["users"])[nubes_postgres_user.db2_user.username]["password"] # 🔑 Пароль
# testds_connectionLimit = "5" # 🚦 Лимит соединений
# testds_liveTimeout = "15" # ⏳ Таймаут жизни
# testds_validate = "false" # ✅ Валидация при запросе
# })
# resource_c_p_u = 300
# resource_memory = 512
# resource_instances = 1
# app_version = "5.4"
# depends_on = [nubes_postgres.db2]
# }
# resource "nubes_nodejs" "app3" {
# # NodeJS демо, работающий с тем же Postgres.
# resource_name = "node_01"
# resource_realm = nubes_postgres.db2.resource_realm
# domain = "node07"
# git_path = "https://gitea-naeel.giteak8s.services.ngcloud.ru/naeel/testnode.git"
# health_path = "/healthz"
# app_version = "23"
# json_env = jsonencode({
# # Переменные подключения к Postgres.
# PGHOST = nubes_postgres.db2.state_out_flat["internalConnect.master"]
# PGPORT = "5432"
# PGUSER = nubes_postgres_user.db2_user.username
# PGPASSWORD = jsondecode(nubes_postgres.db2.vault_secrets["users"])[nubes_postgres_user.db2_user.username]["password"]
# PGDATABASE = nubes_postgres_database.db2_app.db_name
# PGSSLMODE = "require"
# DATABASE_URL = format(
# "postgresql://%s:%s@%s:5432/%s?sslmode=require",
# nubes_postgres_user.db2_user.username,
# jsondecode(nubes_postgres.db2.vault_secrets["users"])[nubes_postgres_user.db2_user.username]["password"],
# nubes_postgres.db2.state_out_flat["internalConnect.master"],
# nubes_postgres_database.db2_app.db_name
# )
# })
# resource_c_p_u = 300
# resource_memory = 256
# resource_instances = 1
# depends_on = [nubes_postgres.db2]
# }
# output "pg_vault_secrets" {
# value = nubes_postgres.db2.vault_secrets
# sensitive = true
# }
# terraform output -json pg_vault_secrets
+58
View File
@@ -0,0 +1,58 @@
// 2026-03-17 17:05
// main.tf — провайдеры и переменные для Nubes + sless.
terraform {
required_providers {
nubes = {
source = "terra.k8c.ru/nubes/nubes"
version = "5.0.19"
}
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.18"
}
}
}
variable "api_token" {
type = string
sensitive = true
description = "Nubes API token"
}
variable "s3_uid" {
type = string
sensitive = true
description = "Nubes S3 UID"
}
variable "realm" {
type = string
sensitive = true
description = "resource_realm parameter for nubes_postgres resource"
}
// 2026-03-18 — pg_user/pg_password помечены optional (default="") для сверки.
// Реальные credentials берутся из vault_secrets через locals в resources.tf.
variable "pg_user" {
type = string
sensitive = true
default = ""
description = "Только для сверки. Реальный username из nubes_postgres_user.pg_user.username. Должен совпадать с vault."
}
variable "pg_password" {
type = string
sensitive = true
default = ""
description = "Только для сверки. Реальный пароль из vault_secrets. Должен совпадать с tfvars."
}
provider "nubes" {
api_token = var.api_token
api_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm"
}
provider "sless" {
endpoint = "https://sless.kube5s.ru"
token = var.api_token
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
}
+196
View File
@@ -0,0 +1,196 @@
// 2026-03-18 — добавлены locals для извлечения credentials из vault_secrets (без хардкода).
// Для сверки хардкод остаётся в terraform.tfvars на этапе разработки.
// sless_function и sless_job закомментированы — сначала проверяется сетевое соединение.
# Актуальные credentials из vault_secrets (authoritatively) — vault синхронизирован с кластером.
# Структура vault_secrets["users"]: JSON-строка {"username": {"password": "...", "username": "..."}}
locals {
pg_creds_map = jsondecode(nubes_postgres.npg.vault_secrets["users"])
pg_username = nubes_postgres_user.pg_user.username
pg_password = local.pg_creds_map[local.pg_username]["password"]
pg_host = nubes_postgres.npg.state_out_flat["internalConnect.master"]
pg_database = nubes_postgres_database.db.db_name
}
resource "nubes_postgres" "npg" {
resource_name = "teststand-pg-2"
# s3_uid = "s01325"
s3_uid = var.s3_uid
resource_realm = var.realm
resource_instances = 1
resource_memory = 512
resource_c_p_u = 500
resource_disk = "1"
app_version = "17"
json_parameters = jsonencode({
log_connections = "off"
log_disconnections = "off"
})
enable_pg_pooler_master = false
enable_pg_pooler_slave = false
allow_no_s_s_l = false
auto_scale = false
auto_scale_percentage = 10
auto_scale_tech_window = 0
auto_scale_quota_gb = "1"
need_external_address_master = false
# suspend_on_destroy = false
operation_timeout = "11m"
adopt_existing_on_create = true
}
resource "nubes_postgres_user" "pg_user" {
postgres_id = nubes_postgres.npg.id
username = "u-user0"
role = "ddl_user"
adopt_existing_on_create = true
}
resource "nubes_postgres_database" "db" {
postgres_id = nubes_postgres.npg.id
db_name = "db_terra"
db_owner = nubes_postgres_user.pg_user.username
adopt_existing_on_create = true
# suspend_on_destroy = false
}
# Служебная функция выполняет SQL-операторы из event_json.
# Credentials берутся из locals (vault_secrets) — без хардкода.
# Для сверки хардкод остаётся в terraform.tfvars.
resource "sless_function" "postgres_sql_runner_create_table" {
name = "pg-create-table-runner"
runtime = "python3.11"
entrypoint = "sql_runner.run_sql"
memory_mb = 128
timeout_sec = 30
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
# Для сверки (должно совпадать с vault):
# PGUSER = var.pg_user
# PGPASSWORD = var.pg_password
}
source_dir = "${path.module}/code/sql-runner"
}
resource "sless_job" "postgres_table_init_job" {
name = "pg-create-table-job-main-v13"
function = sless_function.postgres_sql_runner_create_table.name
wait_timeout_sec = 180
run_id = 13
event_json = jsonencode({
statements = [
"CREATE TABLE IF NOT EXISTS terraform_demo_table (id serial PRIMARY KEY, title text NOT NULL, created_at timestamp DEFAULT now())"
]
})
depends_on = [nubes_postgres_database.db]
}
# HTTP-функция на NodeJS: возвращает версию PG-сервера и счётчик строк в таблице.
# Единственная функция примера на nodejs20 — проверка что JS runtime работает.
# Доступна по URL: https://sless.kube5s.ru/fn/<namespace>/pg-info
resource "sless_function" "pg_info" {
name = "pg-info"
runtime = "nodejs20"
entrypoint = "pg_info.info"
memory_mb = 128
timeout_sec = 15
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
}
source_dir = "${path.module}/code/pg-info"
depends_on = [sless_job.postgres_table_init_job]
}
resource "sless_trigger" "pg_info_http" {
name = "pg-info-http"
type = "http"
function = sless_function.pg_info.name
enabled = true
}
# HTTP-функции чтения и записи строк terraform_demo_table — в одном файле table_rw.py.
# list_rows (GET) — читает все строки; add_row (POST {title}) — вставляет строку.
# Доступны по URL: https://sless.kube5s.ru/fn/<namespace>/pg-table-reader
# https://sless.kube5s.ru/fn/<namespace>/pg-table-writer
resource "sless_function" "postgres_table_reader" {
name = "pg-table-reader"
runtime = "python3.11"
entrypoint = "table_rw.list_rows"
memory_mb = 128
timeout_sec = 30
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
}
source_dir = "${path.module}/code/table-rw"
depends_on = [sless_job.postgres_table_init_job]
}
resource "sless_trigger" "postgres_table_reader_http" {
name = "pg-table-reader-http"
type = "http"
function = sless_function.postgres_table_reader.name
enabled = true
}
output "table_reader_url" {
value = sless_trigger.postgres_table_reader_http.url
}
resource "sless_function" "postgres_table_writer" {
name = "pg-table-writer"
runtime = "python3.11"
entrypoint = "table_rw.add_row"
memory_mb = 128
timeout_sec = 30
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
}
source_dir = "${path.module}/code/table-rw"
depends_on = [sless_job.postgres_table_init_job]
}
resource "sless_trigger" "postgres_table_writer_http" {
name = "pg-table-writer-http"
type = "http"
function = sless_function.postgres_table_writer.name
enabled = true
}
output "table_writer_url" {
value = sless_trigger.postgres_table_writer_http.url
}
@@ -0,0 +1,51 @@
# 2026-03-18 — debug pod для проверки psql-соединения из namespace функций.
# Запускается разово. Подключается к тому же postgres, что и sless_function.
# kubectl apply -f /tmp/pg-debug-pod.yaml
# kubectl logs -n sless-fn-sless-ffd1f598c169b0ae pg-debug-pod
apiVersion: v1
kind: Pod
metadata:
name: pg-debug-pod
namespace: sless-fn-sless-ffd1f598c169b0ae
labels:
purpose: debug-postgres-connectivity
spec:
restartPolicy: Never
containers:
- name: psql
image: postgres:17-alpine
command:
- sh
- -c
- |
echo "=== Testing TCP connectivity to postgres ==="
nc -zv -w5 $PGHOST 5432 && echo "TCP OK" || echo "TCP FAILED"
echo ""
echo "=== Testing psql connection ==="
PGCONNECT_TIMEOUT=10 psql \
"host=$PGHOST port=$PGPORT dbname=$PGDATABASE user=$PGUSER sslmode=$PGSSLMODE" \
--command="SELECT current_user, current_database(), version();" \
2>&1
echo ""
echo "=== Listing tables ==="
PGCONNECT_TIMEOUT=10 psql \
"host=$PGHOST port=$PGPORT dbname=$PGDATABASE user=$PGUSER sslmode=$PGSSLMODE" \
--command="\dt" \
2>&1
env:
- name: PGHOST
value: "postgresqlk8s-master.36875359-dcea-48c4-a593-b4531f20fe96.svc.cluster.local"
- name: PGPORT
value: "5432"
- name: PGDATABASE
value: "db_terra"
- name: PGUSER
value: "u-user0"
- name: PGPASSWORD
# Актуальный пароль из vault_secrets (совпадает с tfvars.pg_password на 2026-03-18)
value: "M03O6fRsngWcVHB2YGivyLfbfxoii2R21nyh2A2r7WSZS5deLwBgLKkc9Wk24Zyl"
- name: PGSSLMODE
value: "require"
@@ -0,0 +1,40 @@
# 2026-03-17 13:05
# read_pg_user_secret.py — читает пароль пользователя managed PostgreSQL из k8s Secret.
# Используется из Terraform external data source, чтобы apply сам получал актуальный пароль
# даже для уже существующего пользователя, созданного вне текущего state.
import base64
import json
import subprocess
import sys
def main():
# Читаем query от Terraform external provider из stdin.
query = json.load(sys.stdin)
namespace = query["namespace"]
secret_name = query["secret"]
# kubectl уже настроен на удалённой машине; читаем ровно поле data.password.
result = subprocess.run(
[
"kubectl",
"get",
"secret",
"-n",
namespace,
secret_name,
"-o",
"jsonpath={.data.password}",
],
check=True,
capture_output=True,
text=True,
)
password = base64.b64decode(result.stdout.strip()).decode()
json.dump({"password": password}, sys.stdout)
if __name__ == "__main__":
main()
+111 -77
View File
@@ -1,64 +1,125 @@
# Примеры sless
# Примеры использования sless
## Что такое sless
## Обзор платформы
**sless** — платформа для запуска serverless-функций в Kubernetes-кластере.
**sless** — система управления serverless-функциями на базе Kubernetes. Разработчик загружает код функции, платформа собирает из него Docker-образ, разворачивает его в кластере и предоставляет HTTP-эндпоинт для вызова. Всё описывается декларативно через Terraform.
Код на Python или Node.js загружается в платформу, которая собирает Docker-образ, деплоит его в кластер и публикует HTTP-эндпоинт. Всё управляется через Terraform.
### Основные ресурсы провайдера
### Ресурсы
| Ресурс | Что делает |
| Ресурс | Назначение |
|---|---|
| `sless_function` | Загружает код и собирает Docker-образ. Сама по себе не принимает запросы — нужен триггер или джоб |
| `sless_trigger` | Публикует функцию — либо как HTTP-эндпоинт, либо по расписанию (cron) |
| `sless_job` | Запускает функцию один раз (например, для инициализации БД) и ждёт результата |
| `sless_function` | Описывает функцию: язык, точку входа, лимиты, переменные окружения. При создании загружает код и запускает его сборку в образ. Сама по себе недоступна снаружи — нужен триггер или задание. |
| `sless_trigger` | Публикует функцию: тип `http` создаёт публичный URL, тип `cron` — запуск по расписанию. |
| `sless_job` | Запускает функцию однократно и ожидает завершения. Используется для одноразовых операций: инициализация БД, миграции, пакетная обработка. |
**Типичный сценарий:** `sless_function` с кодом + `sless_trigger` с `type = "http"` → публичный URL вида `https://sless-api.kube5s.ru/fn/default/имя-функции`.
Стандартная связка для HTTP API: `sless_function` + `sless_trigger` с `type = "http"` — в результате функция доступна по URL вида `https://sless.kube5s.ru/fn/<namespace>/<имя-функции>`.
---
Примеры показывают различные сценарии использования serverless функций через Terraform провайдер `terra.k8c.ru/naeel/sless`.
## Требования
- Terraform >= 1.0
- Доступ к `https://sless-api.kube5s.ru`
- JWT-токен для аутентификации в sless API
- Доступ к `https://sless.kube5s.ru`
## Провайдер
## Конфигурация провайдера
Во всех примерах `main.tf` содержит:
Во всех примерах файл `main.tf` содержит блок провайдера. Токен передаётся через переменную, значение которой задаётся в `terraform.tfvars`:
```hcl
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
token = "dev-token-change-me"
endpoint = "https://sless.kube5s.ru"
token = var.token
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
}
```
Namespace функций вычисляется автоматически из JWT-токена: `sless-{sha256[:8]}`.
> **Перед запуском любого примера** откройте файл `terraform.tfvars` в директории примера и впишите свой токен Nubes API:
> ```hcl
> token = "ваш токен Nubes API"
> ```
> Токен выдаётся в личном кабинете Nubes. Файл `terraform.tfvars` добавлен в `.gitignore` — он не попадёт в репозиторий.
---
## Примеры
### `simple-python` — джоб передаёт результат в HTTP-функцию (Python)
### `hello-node` — минимальный пример на Node.js
При `apply` запускается джоб, его вывод передаётся в HTTP-функцию через `env_vars`.
Две независимые функции: HTTP-функция, возвращающая приветствие, и одноразовое задание, суммирующее набор чисел. Хорошая отправная точка для знакомства с платформой.
```bash
cd hello-node
terraform init
terraform apply -auto-approve
# Вызов HTTP-функции с передачей имени:
curl -s -X POST https://sless.kube5s.ru/fn/<namespace>/hello-http \
-H 'Content-Type: application/json' -d '{"name":"World"}'
# Результат задания:
terraform output job_message
```
---
### `hello-go` — минимальный пример на Go 1.23
Аналог `hello-node`, но на Go. Демонстрирует поддержку Go-рантайма: HTTP-функция и одноразовое задание. Код пользователя оформляется как пакет `handler` с функцией `Handle(event)`.
```bash
cd hello-go
terraform init
terraform apply -auto-approve
terraform output job_message
terraform output trigger_url
```
---
### `pg-list-python` — выборка данных из PostgreSQL (Python)
Минимальный пример работы с базой данных: одна HTTP-функция читает список записей из таблицы PostgreSQL и возвращает их в JSON. Таблица с тестовыми данными создаётся автоматически при первом вызове. Нет заданий, нет инициализации — только функция и триггер.
**Переменные:**
| Переменная | Описание | Значение по умолчанию |
|---|---|---|
| `pg_dsn` | Строка подключения к PostgreSQL | `postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable` |
```bash
cd pg-list-python
terraform init
terraform apply -auto-approve
# URL функции выводится после применения:
terraform output catalog_url
# Запрос к функции:
curl -s $(terraform output -raw catalog_url)
```
---
### `simple-python` — одноразовое задание передаёт данные в HTTP-функцию (Python)
При `apply` выполняется задание, которое фиксирует текущее время. Результат передаётся в HTTP-функцию через переменные окружения и отображается при каждом запросе.
```bash
cd simple-python
terraform init
terraform apply -auto-approve
# Что вернул джоб (время на момент деплоя):
terraform output job_result
# Проверить функцию:
curl -s https://sless-api.kube5s.ru/fn/default/simple-py-time-display
curl -s https://sless.kube5s.ru/fn/<namespace>/simple-py-time-display
```
---
### `simple-node` — то же самое, но на Node.js 20
### `simple-node` — то же самое на Node.js 20
```bash
cd simple-node
@@ -66,95 +127,68 @@ terraform init
terraform apply -auto-approve
terraform output job_result
curl -s https://sless-api.kube5s.ru/fn/default/simple-node-time-display
curl -s https://sless.kube5s.ru/fn/<namespace>/simple-node-time-display
```
---
### `hello-node` — минимальный пример на Node.js
### `notes-python` — CRUD API на Python с PostgreSQL
Две независимые функции: HTTP-функция (возвращает приветствие) и одноразовый джоб (суммирует числа).
```bash
cd hello-node
terraform init
terraform apply -auto-approve
# Проверить HTTP-функцию:
curl -s -X POST https://sless-api.kube5s.ru/fn/default/hello-http \
-H 'Content-Type: application/json' -d '{"name":"World"}'
# Посмотреть результат джоба:
terraform output job_message
```
---
### `notes-python` — CRUD API на Python + PostgreSQL
Полноценное приложение: инициализация схемы БД через джобы, CRUD-функция, read-only функция для списка записей.
Полноценное приложение: инициализация схемы базы данных через задания, CRUD-функция для работы с записями, отдельная функция для получения списка.
**Переменные:**
| Переменная | Описание | Дефолт |
| Переменная | Описание | Значение по умолчанию |
|---|---|---|
| `pg_dsn` | DSN для подключения к PostgreSQL | `postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable` |
| `pg_dsn` | Строка подключения к PostgreSQL | `postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable` |
```bash
cd notes-python
terraform init
# Опционально — переопределить DSN:
# export TF_VAR_pg_dsn="postgres://user:pass@host:5432/db?sslmode=disable"
terraform apply -auto-approve
# Проверить инициализацию БД:
# Статус инициализации базы данных:
terraform output db_init_table_status
terraform output db_init_index_status
# URL функций:
terraform output notes_url # CRUD
terraform output notes_list_url # список всех записей
# Создать запись:
curl -s -X POST "https://sless-api.kube5s.ru/fn/default/notes/add?title=Hello&body=World"
curl -s -X POST "$(terraform output -raw notes_url)/add?title=Hello&body=World"
# Список записей:
curl -s https://sless-api.kube5s.ru/fn/default/notes-list
# Получить список записей:
curl -s $(terraform output -raw notes_list_url)
# Обновить (id из предыдущего ответа):
curl -s -X POST "https://sless-api.kube5s.ru/fn/default/notes/update?id=1&title=Updated&body=New+body"
# Обновить запись (id из предыдущего ответа):
curl -s -X POST "$(terraform output -raw notes_url)/update?id=1&title=Updated&body=New+body"
# Удалить:
curl -s -X POST "https://sless-api.kube5s.ru/fn/default/notes/delete?id=1"
# Удалить запись:
curl -s -X POST "$(terraform output -raw notes_url)/delete?id=1"
```
---
## Общие команды
## Полезные команды
```bash
# Посмотреть текущее состояние ресурсов:
# Посмотреть текущее состояние задеплоенных ресурсов:
terraform show
# Пересоздать конкретный ресурс:
terraform apply -replace=sless_function.имя -auto-approve
# Принудительно пересобрать функцию (например, после изменения кода):
terraform apply -replace=sless_function.<имя> -auto-approve
# Повторно запустить джоб — увеличить run_id в .tf файле, затем:
# Повторно запустить задание: увеличить значение run_id в .tf-файле, затем:
terraform apply -auto-approve
# Удалить все ресурсы примера:
terraform destroy -auto-approve
```
## Структура каждого примера
## Структура примера
```
пример/
├── main.tf — провайдер
├── *.tf — ресурсы (функции, триггеры, джобы)
├── outputs.tf — URLs и статусы после apply
├── variables.tf — входные переменные (если есть)
└── code/ — исходный код функций
<пример>/
├── main.tf — конфигурация провайдера
├── *.tf — ресурсы: функции, триггеры, задания
├── variables.tf — входные переменные
├── terraform.tfvars — значения переменных (не коммитится в git)
└── code/ — исходный код функций
```
@@ -0,0 +1,45 @@
# Изменено: 2026-03-14
# Функция event-cleaner: удаляет N самых старых строк из таблицы events.
# Вызывается через HTTP POST из Node-RED (который слушает RabbitMQ).
# Env: POSTGRES_DSN — строка подключения к PostgreSQL.
import os
import json
import psycopg2
def handle(request):
"""Удаляет N старейших строк из таблицы events."""
dsn = os.environ["POSTGRES_DSN"]
body = {}
if request.get_data():
try:
body = json.loads(request.get_data())
except Exception:
pass
# Количество строк для удаления — из тела запроса или дефолт 10
delete_n = int(body.get("delete_n", 10))
# Защита от случайного удаления слишком большого количества строк
delete_n = min(delete_n, 100)
conn = psycopg2.connect(dsn)
try:
with conn.cursor() as cur:
cur.execute("""
DELETE FROM events
WHERE id IN (
SELECT id FROM events ORDER BY created_at ASC LIMIT %s
)
""", (delete_n,))
deleted = cur.rowcount
cur.execute("SELECT COUNT(*) FROM events")
remaining = cur.fetchone()[0]
conn.commit()
return json.dumps({
"ok": True,
"deleted": deleted,
"remaining": remaining
}), 200, {"Content-Type": "application/json"}
finally:
conn.close()
@@ -0,0 +1,47 @@
# Изменено: 2026-03-14
# Функция event-monitor: считает строки в events.
# Если больше 50 — публикует сообщение в RabbitMQ queue "cleanup-needed".
# Запускается по cron (каждую минуту).
# Env:
# POSTGRES_DSN — строка подключения к PostgreSQL
# RABBITMQ_URL — amqp://sless:sless123@rabbitmq.sless.svc.cluster.local:5672/
import os
import json
import psycopg2
import pika
THRESHOLD = 50
def handle(request):
"""Мониторит таблицу events. При переполнении шлёт в RabbitMQ."""
dsn = os.environ["POSTGRES_DSN"]
rabbit_url = os.environ["RABBITMQ_URL"]
conn = psycopg2.connect(dsn)
try:
with conn.cursor() as cur:
# Считаем количество событий
cur.execute("SELECT COUNT(*) FROM events")
count = cur.fetchone()[0]
finally:
conn.close()
result = {"count": count, "threshold": THRESHOLD, "action": "none"}
if count > THRESHOLD:
# Публикуем в очередь — event-cleaner получит и удалит старые строки
params = pika.URLParameters(rabbit_url)
connection = pika.BlockingConnection(params)
channel = connection.channel()
channel.queue_declare(queue="cleanup-needed", durable=True)
channel.basic_publish(
exchange="",
routing_key="cleanup-needed",
body=json.dumps({"count": count, "delete_n": 10}),
properties=pika.BasicProperties(delivery_mode=2) # persistent
)
connection.close()
result["action"] = "cleanup_requested"
return json.dumps(result), 200, {"Content-Type": "application/json"}
@@ -0,0 +1,49 @@
# Изменено: 2026-03-14
# Функция event-writer: принимает HTTP POST, пишет одну строку в таблицу events.
# Таблица создаётся автоматически при первом запуске.
# Env: POSTGRES_DSN — строка подключения к PostgreSQL.
import os
import json
import psycopg2
from datetime import datetime, timezone
def handle(request):
"""Записывает одно событие в таблицу events."""
dsn = os.environ["POSTGRES_DSN"]
body = {}
if request.get_data():
try:
body = json.loads(request.get_data())
except Exception:
pass
source = body.get("source", "node-red")
message = body.get("message", "ping")
conn = psycopg2.connect(dsn)
try:
with conn.cursor() as cur:
# Создаём таблицу если нет — безопасно вызывать при каждом запросе
cur.execute("""
CREATE TABLE IF NOT EXISTS events (
id SERIAL PRIMARY KEY,
source VARCHAR(100) NOT NULL DEFAULT 'unknown',
message TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
cur.execute(
"INSERT INTO events (source, message) VALUES (%s, %s) RETURNING id, created_at",
(source, message)
)
row = cur.fetchone()
conn.commit()
return json.dumps({
"ok": True,
"id": row[0],
"created_at": row[1].isoformat()
}), 200, {"Content-Type": "application/json"}
finally:
conn.close()
+194
View File
@@ -0,0 +1,194 @@
# Примеры использования sless
## Обзор платформы
**sless** — система управления serverless-функциями на базе Kubernetes. Разработчик загружает код функции, платформа собирает из него Docker-образ, разворачивает его в кластере и предоставляет HTTP-эндпоинт для вызова. Всё описывается декларативно через Terraform.
### Основные ресурсы провайдера
| Ресурс | Назначение |
|---|---|
| `sless_function` | Описывает функцию: язык, точку входа, лимиты, переменные окружения. При создании загружает код и запускает его сборку в образ. Сама по себе недоступна снаружи — нужен триггер или задание. |
| `sless_trigger` | Публикует функцию: тип `http` создаёт публичный URL, тип `cron` — запуск по расписанию. |
| `sless_job` | Запускает функцию однократно и ожидает завершения. Используется для одноразовых операций: инициализация БД, миграции, пакетная обработка. |
Стандартная связка для HTTP API: `sless_function` + `sless_trigger` с `type = "http"` — в результате функция доступна по URL вида `https://sless.kube5s.ru/fn/<namespace>/<имя-функции>`.
---
## Требования
- Terraform >= 1.0
- JWT-токен для аутентификации в sless API
- Доступ к `https://sless.kube5s.ru`
## Конфигурация провайдера
Во всех примерах файл `main.tf` содержит блок провайдера. Токен передаётся через переменную, значение которой задаётся в `terraform.tfvars`:
```hcl
provider "sless" {
endpoint = "https://sless.kube5s.ru"
token = var.token
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
}
```
Namespace функций вычисляется автоматически из JWT-токена: `sless-{sha256[:8]}`.
> **Перед запуском любого примера** откройте файл `terraform.tfvars` в директории примера и впишите свой токен Nubes API:
> ```hcl
> token = "ваш токен Nubes API"
> ```
> Токен выдаётся в личном кабинете Nubes. Файл `terraform.tfvars` добавлен в `.gitignore` — он не попадёт в репозиторий.
---
## Примеры
### `hello-node` — минимальный пример на Node.js
Две независимые функции: HTTP-функция, возвращающая приветствие, и одноразовое задание, суммирующее набор чисел. Хорошая отправная точка для знакомства с платформой.
```bash
cd hello-node
terraform init
terraform apply -auto-approve
# Вызов HTTP-функции с передачей имени:
curl -s -X POST https://sless.kube5s.ru/fn/<namespace>/hello-http \
-H 'Content-Type: application/json' -d '{"name":"World"}'
# Результат задания:
terraform output job_message
```
---
### `hello-go` — минимальный пример на Go 1.23
Аналог `hello-node`, но на Go. Демонстрирует поддержку Go-рантайма: HTTP-функция и одноразовое задание. Код пользователя оформляется как пакет `handler` с функцией `Handle(event)`.
```bash
cd hello-go
terraform init
terraform apply -auto-approve
terraform output job_message
terraform output trigger_url
```
---
### `pg-list-python` — выборка данных из PostgreSQL (Python)
Минимальный пример работы с базой данных: одна HTTP-функция читает список записей из таблицы PostgreSQL и возвращает их в JSON. Таблица с тестовыми данными создаётся автоматически при первом вызове. Нет заданий, нет инициализации — только функция и триггер.
**Переменные:**
| Переменная | Описание | Значение по умолчанию |
|---|---|---|
| `pg_dsn` | Строка подключения к PostgreSQL | `postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable` |
```bash
cd pg-list-python
terraform init
terraform apply -auto-approve
# URL функции выводится после применения:
terraform output catalog_url
# Запрос к функции:
curl -s $(terraform output -raw catalog_url)
```
---
### `simple-python` — одноразовое задание передаёт данные в HTTP-функцию (Python)
При `apply` выполняется задание, которое фиксирует текущее время. Результат передаётся в HTTP-функцию через переменные окружения и отображается при каждом запросе.
```bash
cd simple-python
terraform init
terraform apply -auto-approve
terraform output job_result
curl -s https://sless.kube5s.ru/fn/<namespace>/simple-py-time-display
```
---
### `simple-node` — то же самое на Node.js 20
```bash
cd simple-node
terraform init
terraform apply -auto-approve
terraform output job_result
curl -s https://sless.kube5s.ru/fn/<namespace>/simple-node-time-display
```
---
### `notes-python` — CRUD API на Python с PostgreSQL
Полноценное приложение: инициализация схемы базы данных через задания, CRUD-функция для работы с записями, отдельная функция для получения списка.
**Переменные:**
| Переменная | Описание | Значение по умолчанию |
|---|---|---|
| `pg_dsn` | Строка подключения к PostgreSQL | `postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable` |
```bash
cd notes-python
terraform init
terraform apply -auto-approve
# Статус инициализации базы данных:
terraform output db_init_table_status
terraform output db_init_index_status
# Создать запись:
curl -s -X POST "$(terraform output -raw notes_url)/add?title=Hello&body=World"
# Получить список записей:
curl -s $(terraform output -raw notes_list_url)
# Обновить запись (id из предыдущего ответа):
curl -s -X POST "$(terraform output -raw notes_url)/update?id=1&title=Updated&body=New+body"
# Удалить запись:
curl -s -X POST "$(terraform output -raw notes_url)/delete?id=1"
```
---
## Полезные команды
```bash
# Посмотреть текущее состояние задеплоенных ресурсов:
terraform show
# Принудительно пересобрать функцию (например, после изменения кода):
terraform apply -replace=sless_function.<имя> -auto-approve
# Повторно запустить задание: увеличить значение run_id в .tf-файле, затем:
terraform apply -auto-approve
# Удалить все ресурсы примера:
terraform destroy -auto-approve
```
## Структура примера
```
<пример>/
├── main.tf — конфигурация провайдера
├── *.tf — ресурсы: функции, триггеры, задания
├── variables.tf — входные переменные
├── terraform.tfvars — значения переменных (не коммитится в git)
└── code/ — исходный код функций
```
@@ -0,0 +1,26 @@
# Изменено: 2026-03-14
# event-cleaner: HTTP функция для демонстрации контролируемого изменения логики.
# Работает без внешних пакетов.
import json
from datetime import datetime, timezone
def event_cleaner_handle(event_cleaner_event):
"""Принимает delete_n и возвращает подтверждение обработки."""
event_cleaner_payload = event_cleaner_event if isinstance(event_cleaner_event, dict) else {}
event_cleaner_delete_n = int(event_cleaner_payload.get("delete_n", 10))
event_cleaner_delete_n = max(1, min(event_cleaner_delete_n, 100))
event_cleaner_generated_at = datetime.now(timezone.utc).isoformat()
# Здесь intentionally имитируем очистку, чтобы показать реакцию на входные параметры.
return json.dumps(
{
"ok": True,
"accepted_delete_n": event_cleaner_delete_n,
"status": "simulated-cleanup",
"generated_at": event_cleaner_generated_at,
}
), 200, {"Content-Type": "application/json"}
@@ -0,0 +1,25 @@
# Изменено: 2026-03-14
# event-monitor: cron-функция для демонстрации расписания и управления кодом.
# Работает без внешних библиотек и возвращает диагностический JSON.
import json
import os
from datetime import datetime, timezone
EVENT_MONITOR_THRESHOLD = 50
def event_monitor_handle(event_monitor_event):
"""Отдаёт heartbeat для cron-запуска и видимой проверки после apply."""
event_monitor_rabbitmq_url = os.environ.get("RABBITMQ_URL", "not-set")
event_monitor_generated_at = datetime.now(timezone.utc).isoformat()
return json.dumps(
{
"ok": True,
"monitor": "alive",
"threshold": EVENT_MONITOR_THRESHOLD,
"rabbitmq_configured": event_monitor_rabbitmq_url != "not-set",
"generated_at": event_monitor_generated_at,
}
), 200, {"Content-Type": "application/json"}
@@ -0,0 +1,28 @@
# Изменено: 2026-03-14
# event-writer: простая HTTP функция без внешних зависимостей.
# Правка поля response_tag в коде сразу видна в ответе после terraform apply.
import json
import os
from datetime import datetime, timezone
def event_writer_handle(event_writer_event):
"""Возвращает полезный JSON-ответ для визуальной проверки выката кода."""
writer_default_message = os.environ.get("DEFAULT_MESSAGE", "writer-default")
writer_payload = event_writer_event if isinstance(event_writer_event, dict) else {}
writer_message_value = writer_payload.get("message", writer_default_message)
writer_source_name = writer_payload.get("source", "event-writer")
writer_generated_at = datetime.now(timezone.utc).isoformat()
# response_tag удобно менять для демонстрации hot-update кода через terraform apply.
writer_response_tag = "writer-v2"
return {
"ok": True,
"source": writer_source_name,
"message": writer_message_value,
"response_tag": writer_response_tag,
"generated_at": writer_generated_at,
}
@@ -0,0 +1,77 @@
# 2026-03-14
# function.tf — ресурсы managed serverless функций для демонстрации.
# Пользователь правит код в code/* и запускает terraform apply — провайдер сам пересобирает и выкатывает функции.
resource "sless_function" "event_writer" {
name = "event-writer"
runtime = "python3.11"
entrypoint = "event_writer_handler.event_writer_handle"
memory_mb = 128
timeout_sec = 20
source_dir = "${path.module}/code/event-writer"
env_vars = {
POSTGRES_DSN = var.pg_dsn
DEFAULT_MESSAGE = var.writer_message
}
}
resource "sless_trigger" "event_writer_http" {
name = "event-writer-http"
type = "http"
function = sless_function.event_writer.name
enabled = true
}
resource "sless_function" "event_monitor" {
name = "event-monitor"
runtime = "python3.11"
entrypoint = "event_monitor_handler.event_monitor_handle"
memory_mb = 128
timeout_sec = 20
source_dir = "${path.module}/code/event-monitor"
env_vars = {
POSTGRES_DSN = var.pg_dsn
RABBITMQ_URL = var.rabbitmq_url
}
}
resource "sless_trigger" "event_monitor_cron" {
name = "event-monitor-cron"
type = "cron"
function = sless_function.event_monitor.name
enabled = true
schedule = "*/1 * * * *"
}
resource "sless_function" "event_cleaner" {
name = "event-cleaner"
runtime = "python3.11"
entrypoint = "event_cleaner_handler.event_cleaner_handle"
memory_mb = 128
timeout_sec = 20
source_dir = "${path.module}/code/event-cleaner"
env_vars = {
POSTGRES_DSN = var.pg_dsn
}
}
resource "sless_trigger" "event_cleaner_http" {
name = "event-cleaner-http"
type = "http"
function = sless_function.event_cleaner.name
enabled = true
}
output "event_writer_url" {
value = sless_trigger.event_writer_http.url
}
output "event_cleaner_url" {
value = sless_trigger.event_cleaner_http.url
}
+18
View File
@@ -0,0 +1,18 @@
# 2026-03-14
# Terraform demo: managed serverless functions.
# Здесь управляем ТОЛЬКО функциями/триггерами, внешние сервисы считаем уже поднятыми.
terraform {
required_providers {
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.18"
}
}
}
provider "sless" {
endpoint = var.sless_endpoint
token = var.token
nubes_endpoint = var.nubes_endpoint
}
@@ -0,0 +1,11 @@
# 2026-03-14
# Скопируй в terraform.tfvars и подставь актуальный токен.
token = "PUT_TOKEN_HERE"
sless_endpoint = "https://sless-api.kube5s.ru"
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
pg_dsn = "postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable"
rabbitmq_url = "amqp://sless:sless123@rabbitmq.sless.svc.cluster.local:5672/"
writer_message = "writer-default-v1"
@@ -0,0 +1,38 @@
# 2026-03-14
# Переменные для demo-managed-functions.
variable "token" {
description = "JWT токен API"
type = string
sensitive = true
}
variable "sless_endpoint" {
description = "Endpoint sless API"
type = string
default = "https://sless.kube5s.ru"
}
variable "nubes_endpoint" {
description = "Nubes endpoint (нужен провайдеру)"
type = string
default = "https://deck-api-test.ngcloud.ru/api/v1"
}
variable "pg_dsn" {
description = "PostgreSQL DSN для функций"
type = string
default = "postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable"
}
variable "rabbitmq_url" {
description = "RabbitMQ URL для функций"
type = string
default = "amqp://sless:sless123@rabbitmq.sless.svc.cluster.local:5672/"
}
variable "writer_message" {
description = "Сообщение по умолчанию, которое пишет event-writer"
type = string
default = "writer-default-v1"
}
+29
View File
@@ -0,0 +1,29 @@
// Изменено: 2026-03-11
// greeting.go — пример Go функции: возвращает приветствие.
//
// ТРЕБОВАНИЕ РАНТАЙМА: пакет должен называться handler, точка входа — Handle.
// Вся бизнес-логика — в отдельных функциях с нормальными именами.
package handler
import "fmt"
// buildGreeting — формирует текст приветствия для указанного имени гостя.
func buildGreeting(guestName string) string {
return fmt.Sprintf("Hello, %s! (Go 1.23)", guestName)
}
// Handle — точка входа, вызывается рантаймом на каждый запрос/событие.
// Не переименовывать: это жёсткий контракт рантайма (server.go вызывает handler.Handle).
func Handle(event map[string]interface{}) interface{} {
guestName, ok := event["name"].(string)
if !ok || guestName == "" {
guestName = "world"
}
return map[string]interface{}{
"message": buildGreeting(guestName),
"runtime": "go1.23",
"event": event,
}
}
+23
View File
@@ -0,0 +1,23 @@
# 2026-03-11
# http.tf — HTTP-функция на Go: принимает запрос, возвращает приветствие.
resource "sless_function" "hello_go_http" {
name = "hello-go-http"
runtime = "go1.23"
entrypoint = "handler.Handle"
memory_mb = 128
timeout_sec = 60
source_dir = "${path.module}/code"
}
resource "sless_trigger" "hello_go_http" {
name = "hello-go-http-trigger"
type = "http"
function = sless_function.hello_go_http.name
enabled = true
}
output "trigger_url" {
value = sless_trigger.hello_go_http.url
}
+28
View File
@@ -0,0 +1,28 @@
# 2026-03-11
# job.tf — одноразовый запуск Go функции с event payload.
resource "sless_function" "hello_go_job" {
name = "hello-go-job"
runtime = "go1.23"
entrypoint = "handler.Handle"
memory_mb = 128
timeout_sec = 60
source_dir = "${path.module}/code"
}
resource "sless_job" "hello_go_run" {
name = "hello-go-run"
function = sless_function.hello_go_job.name
event_json = jsonencode({ name = "Go" })
wait_timeout_sec = 300
run_id = 1
}
output "job_phase" {
value = sless_job.hello_go_run.phase
}
output "job_message" {
value = sless_job.hello_go_run.message
}
+17
View File
@@ -0,0 +1,17 @@
# 2026-03-11
# main.tf — провайдеры для hello-go примера.
terraform {
required_providers {
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.18"
}
}
}
provider "sless" {
endpoint = "https://sless.kube5s.ru"
token = var.token
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
}
@@ -0,0 +1 @@
token = "замени на Nubes API token" и переименуй файл в terraform.tfvars
+10
View File
@@ -0,0 +1,10 @@
# 2026-03-11
# variables.tf — входные переменные для hello-node примера.
# JWT токен облака (nubes). Передаётся через terraform.tfvars (gitignored).
# Из токена провайдер вычисляет namespace: sless-{sha256[:8]}
variable "token" {
description = "JWT токен облака для аутентификации в sless API"
type = string
sensitive = true
}
+3 -3
View File
@@ -11,14 +11,14 @@ terraform {
required_providers {
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.13"
version = "~> 0.1.18"
}
}
}
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
endpoint = "https://sless.kube5s.ru"
token = var.token
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
}
@@ -0,0 +1 @@
token = "замени на Nubes API token" и переименуй файл в terraform.tfvars
+3 -3
View File
@@ -14,14 +14,14 @@ terraform {
# Провайдер для управления serverless функциями через sless API
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.13"
version = "~> 0.1.18"
}
}
}
# sless провайдер подключается к API кластера.
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
endpoint = "https://sless.kube5s.ru"
token = var.token
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
}
@@ -0,0 +1 @@
token = "замени на Nubes API token" и переименуй файл в terraform.tfvars
+102
View File
@@ -0,0 +1,102 @@
# pg-list-python
Python-функция, которая читает из PostgreSQL и возвращает JSON. Таблица с демо-данными создаётся автоматически при первом вызове — никакой инициализации руками.
## Что тут есть
```
code/
catalog.py — функция list_products(event): SELECT из demo_products
requirements.txt — psycopg2-binary
function.tf — sless_function + sless_trigger + output
main.tf — конфигурация провайдера
variables.tf — token, pg_dsn
terraform.tfvars — значения переменных (не в git)
```
## Запуск
**1. Впишите токен в `terraform.tfvars`:**
```hcl
# terraform.tfvars
token = "ваш токен Nubes API" # ← заменить на реальный токен из личного кабинета Nubes
```
**2. Деплой:**
```bash
terraform init
terraform apply -auto-approve
curl -s $(terraform output -raw catalog_url)
```
Ответ:
```json
{
"products": [
{"id": 1, "name": "Ноутбук", "price": 89999.0},
{"id": 2, "name": "Мышь", "price": 1299.0},
{"id": 3, "name": "Клавиатура", "price": 3499.0},
{"id": 4, "name": "Монитор", "price": 32000.0}
],
"count": 4
}
```
## Переменные
| Переменная | Обязательна | Описание |
|---|---|---|
| `token` | ✅ | JWT-токен облака. Задаётся в `terraform.tfvars` |
| `pg_dsn` | — | DSN подключения к PostgreSQL. Дефолт — внутрикластерный адрес |
`terraform.tfvars` не коммитится в git. Минимальное содержимое:
```hcl
token = "ваш токен Nubes API"
```
## Как работает
```
terraform apply
→ загружает код в S3
→ kaniko собирает Docker-образ (pip install psycopg2-binary)
→ Deployment поднимается в кластере
→ Ingress публикует URL
GET /fn/<namespace>/product-catalog
→ рантайм вызывает list_products(event)
→ psycopg2 подключается к PostgreSQL
→ если таблица demo_products не существует — создаёт и заполняет демо-данными
→ возвращает JSON со списком
```
Таблица `demo_products` создаётся один раз — при первом GET. Повторные вызовы просто читают данные.
## Изменение данных
Функция только читает. Чтобы добавить/изменить записи — подключитесь к PostgreSQL напрямую:
```sql
INSERT INTO demo_products (name, price) VALUES ('Кабель', 499.00);
UPDATE demo_products SET price = 95000 WHERE name = 'Ноутбук';
```
## Пересборка если изменили код
```bash
terraform apply -replace=sless_function.product_catalog -auto-approve
```
## Удаление
```bash
terraform destroy -auto-approve
```
Таблица `demo_products` в PostgreSQL **не удаляется** — только k8s-ресурсы.
+47
View File
@@ -0,0 +1,47 @@
# 2026-03-11
# catalog.py — читает список продуктов из PostgreSQL.
# Таблица demo_products создаётся автоматически при первом вызове.
# Точка входа: list_products(event)
import json
import os
import psycopg2
def list_products(event):
dsn = os.environ["PG_DSN"]
conn = psycopg2.connect(dsn)
try:
with conn.cursor() as cur:
_ensure_table(cur)
conn.commit()
cur.execute("SELECT id, name, price FROM demo_products ORDER BY id")
rows = cur.fetchall()
finally:
conn.close()
products = [{"id": row[0], "name": row[1], "price": float(row[2])} for row in rows]
return {"products": products, "count": len(products)}
def _ensure_table(cur):
# Создаём таблицу и наполняем демо-данными — только один раз
cur.execute("""
CREATE TABLE IF NOT EXISTS demo_products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
price NUMERIC(10,2) NOT NULL
)
""")
cur.execute("SELECT COUNT(*) FROM demo_products")
if cur.fetchone()[0] == 0:
cur.executemany(
"INSERT INTO demo_products (name, price) VALUES (%s, %s)",
[
("Ноутбук", 89999.00),
("Мышь", 1299.00),
("Клавиатура", 3499.00),
("Монитор", 32000.00),
],
)
@@ -0,0 +1 @@
psycopg2-binary
+29
View File
@@ -0,0 +1,29 @@
# 2026-03-11
# function.tf — HTTP-функция: читает из PostgreSQL и возвращает список записей.
# Нет джобов, нет инициализации — только функция + HTTP триггер.
resource "sless_function" "product_catalog" { # объявляем serverless-функцию; "product_catalog" — локальное имя в tf-state
name = "product-catalog" # имя функции в кластере; по нему формируется URL и имя k8s-объекта
runtime = "python3.11" # базовый образ рантайма; определяет как собирается и запускается код
entrypoint = "catalog.list_products" # файл.функция которую вызывает рантайм: catalog.py → def list_products(event)
memory_mb = 128 # лимит памяти пода в мегабайтах
timeout_sec = 10 # максимальное время выполнения одного запроса в секундах
source_dir = "${path.module}/code" # директория с кодом функции; провайдер упакует её в zip и загрузит
# DSN передаётся через env — функция не знает об инфраструктуре
env_vars = {
PG_DSN = var.pg_dsn # строка подключения к PostgreSQL; берётся из переменной (variables.tf)
}
}
resource "sless_trigger" "product_catalog_http" { # триггер публикует функцию наружу; без него функция существует, но недоступна
name = "product-catalog-http" # имя триггера в кластере
type = "http" # тип триггера: "http" создаёт публичный URL; альтернатива — "cron"
function = sless_function.product_catalog.name # ссылка на имя функции выше; terraform гарантирует порядок создания
enabled = true # триггер активен сразу после создания
}
output "catalog_url" {
value = sless_trigger.product_catalog_http.url # URL вида https://sless-api.../fn/<namespace>/<name>; выводится после apply
}
+17
View File
@@ -0,0 +1,17 @@
# 2026-03-11
# main.tf — провайдер для pg-list-python примера.
terraform {
required_providers {
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.18"
}
}
}
provider "sless" {
endpoint = "https://sless.kube5s.ru"
token = var.token
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
}
@@ -0,0 +1 @@
token = "замени на Nubes API token" и переименуй файл в terraform.tfvars
+14
View File
@@ -0,0 +1,14 @@
# 2026-03-11
# variables.tf
variable "token" {
description = "JWT токен облака"
type = string
sensitive = true
}
variable "pg_dsn" {
description = "DSN подключения к PostgreSQL"
type = string
default = "postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable"
}
-7
View File
@@ -1,7 +0,0 @@
# 2026-03-11 10:00
# Minimal sample image to push to PearlHarbor registry
# Purpose: небольшой образ для тестирования пуша в реестр
FROM alpine:3.18
CMD ["sh", "-c", "echo Hello from pearlharbor sample image"]
-28
View File
@@ -1,28 +0,0 @@
# Пример для пуша в PearlHarbor
Файлы:
- [examples/push-sample/Dockerfile](examples/push-sample/Dockerfile) — минимальный образ
- [examples/push-sample/build_and_push.sh](examples/push-sample/build_and_push.sh) — сборка и опциональный пуш
Как использовать:
1. Сборка локально (в корне репы):
```bash
docker build -t sless-sample:local -f examples/push-sample/Dockerfile examples/push-sample
```
2. Протестировать скрипт (скрипт не будет пушить без переменной DO_PUSH):
```bash
cd examples/push-sample
./build_and_push.sh
```
3. Для реального пуша установите `DO_PUSH=true`. Скрипт прочитает `secrets/pearlharbor_registry.txt`.
```bash
DO_PUSH=true ./build_and_push.sh
```
Примечание: скрипт использует по умолчанию пользователя `admin`. Для другого пользователя задайте `REGISTRY_USER`.
-53
View File
@@ -1,53 +0,0 @@
#!/usr/bin/env bash
# 2026-03-11 10:02
# Скрипт: собирает минимальный образ и, при разрешении, пушит в реестр PearlHarbor
# Требования: `docker` в PATH. Скрипт НЕ будет пушить без DO_PUSH=true.
set -euo pipefail
# Получаем значения из файла секретов
SECRETS_FILE="secrets/pearlharbor_registry.txt"
if [ ! -f "$SECRETS_FILE" ]; then
echo "Файл с секретами не найден: $SECRETS_FILE"
exit 1
fi
connection_url=$(grep -E '^connection_url=' "$SECRETS_FILE" | cut -d'=' -f2-)
admin_pass=$(grep -E '^admin_pass=' "$SECRETS_FILE" | cut -d'=' -f2-)
if [ -z "$connection_url" ]; then
echo "Не найден connection_url в $SECRETS_FILE"
exit 1
fi
# Убираем протокол и возможный слеш на конце
registry_host=$(echo "$connection_url" | sed -E 's~https?://~~' | sed -E 's~/$~~')
image_name="$registry_host/sless-sample:latest"
echo "Registry host: $registry_host"
echo "Image name: $image_name"
echo "Собираю образ локально..."
docker build -t sless-sample:local -f Dockerfile .. || {
echo "Сборка не удалась"; exit 1
}
echo "Готово. Образ: sless-sample:local"
if [ "${DO_PUSH:-}" != "true" ]; then
echo "DO_PUSH != true — пуш не будет выполнен. Чтобы запушить: DO_PUSH=true ./build_and_push.sh"
exit 0
fi
# Если дошли до сюда — выполняем login/push
registry_user=${REGISTRY_USER:-admin}
echo "Выполняю docker login к $registry_host как '$registry_user'"
echo "$admin_pass" | docker login "$registry_host" -u "$registry_user" --password-stdin
echo "Тегирую и пушу образ: $image_name"
docker tag sless-sample:local "$image_name"
docker push "$image_name"
echo "Пуш завершён. Проверьте реестр для образа: $image_name"
-333
View File
@@ -1,333 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_DIR="$ROOT_DIR/.test-logs"
mkdir -p "$LOG_DIR"
EXAMPLES=(
"hello-node"
"simple-node"
"simple-python"
"notes-python"
)
declare -A CHECK_METHOD=(
[hello-node]="POST"
[simple-node]="GET"
[simple-python]="GET"
[notes-python]="GET"
)
declare -A CHECK_URL=(
[hello-node]="https://sless-api.kube5s.ru/fn/default/hello-http"
[simple-node]="https://sless-api.kube5s.ru/fn/default/simple-node-time-display"
[simple-python]="https://sless-api.kube5s.ru/fn/default/simple-py-time-display"
[notes-python]="https://sless-api.kube5s.ru/fn/default/notes-list"
)
declare -A CHECK_DATA=(
[hello-node]='{"name":"Smoke"}'
[simple-node]=''
[simple-python]=''
[notes-python]=''
)
declare -a PREEXISTING_EXAMPLES=()
LAST_LOG_FILE=""
CURRENT_EXAMPLE=""
CURRENT_STEP=""
LAST_ERROR_SUMMARY=""
fail_run() {
local example="$1"
local step="$2"
local details="$3"
echo
echo "ERROR SUMMARY"
echo "example: $example"
echo "step: $step"
echo "reason: $details"
if [ -n "$LAST_LOG_FILE" ] && [ -f "$LAST_LOG_FILE" ]; then
echo "log: $LAST_LOG_FILE"
echo "last log lines:"
tail -n 20 "$LAST_LOG_FILE"
fi
exit 1
}
run_step() {
local example="$1"
local step="$2"
shift 2
CURRENT_EXAMPLE="$example"
CURRENT_STEP="$step"
LAST_ERROR_SUMMARY=""
if ! "$@"; then
local details="$LAST_ERROR_SUMMARY"
if [ -z "$details" ]; then
details="step failed without explicit summary"
fi
fail_run "$example" "$step" "$details"
fi
}
restore_any_backups() {
local backup
while IFS= read -r backup; do
[ -n "$backup" ] || continue
if [ -f "$backup" ]; then
mv "$backup" "${backup%.copilot.bak}"
fi
done < <(find "$ROOT_DIR" -name '*.copilot.bak' | sort)
}
trap restore_any_backups EXIT
clean_local_artifacts() {
local example="$1"
rm -rf \
"$ROOT_DIR/$example/.terraform" \
"$ROOT_DIR/$example/.terraform.lock.hcl" \
"$ROOT_DIR/$example/terraform.tfstate" \
"$ROOT_DIR/$example/terraform.tfstate.backup" \
"$ROOT_DIR/$example"/terraform.tfstate.*.backup \
"$ROOT_DIR/$example/dist"
}
retry_tf() {
local example="$1"
local label="$2"
shift 2
local attempt=1
while [ "$attempt" -le 3 ]; do
LAST_LOG_FILE="$LOG_DIR/${example//\//_}-${label// /_}-${attempt}.log"
echo "==> [$example] $label (attempt $attempt/3)"
(
cd "$ROOT_DIR/$example"
"$@"
) 2>&1 | tee "$LAST_LOG_FILE"
local status=${PIPESTATUS[0]}
if [ "$status" -eq 0 ]; then
return 0
fi
if grep -Eiq 'Unauthorized|401|403' "$LAST_LOG_FILE"; then
LAST_ERROR_SUMMARY="authorization error during $label"
echo "[$example] authorization error during $label"
return 41
fi
if grep -Eiq 'TLS handshake timeout|tls:.*timeout|i/o timeout|Client\.Timeout exceeded while awaiting headers|context deadline exceeded|unexpected EOF' "$LAST_LOG_FILE" && [ "$attempt" -lt 3 ]; then
attempt=$((attempt + 1))
sleep 2
continue
fi
if grep -Eiq 'TLS handshake timeout|tls:.*timeout|i/o timeout|Client\.Timeout exceeded while awaiting headers|context deadline exceeded|unexpected EOF' "$LAST_LOG_FILE"; then
LAST_ERROR_SUMMARY="network/provider download failure during $label after retries"
else
LAST_ERROR_SUMMARY="terraform command failed during $label with exit code $status"
fi
return "$status"
done
LAST_ERROR_SUMMARY="terraform command failed during $label after exhausting retries"
return 1
}
record_preexisting_if_needed() {
local example="$1"
if grep -Fq 'No changes. Your infrastructure matches the configuration.' "$LAST_LOG_FILE"; then
PREEXISTING_EXAMPLES+=("$example")
echo "[$example] detected preexisting remote resources on clean apply"
fi
}
probe_endpoint() {
local example="$1"
local body_file="$LOG_DIR/${example//\//_}-endpoint-body.txt"
local status_file="$LOG_DIR/${example//\//_}-endpoint-status.txt"
local method="${CHECK_METHOD[$example]}"
local url="${CHECK_URL[$example]}"
local data="${CHECK_DATA[$example]}"
if [ "$method" = "POST" ]; then
curl -sS -X POST -H 'Content-Type: application/json' -d "$data" -o "$body_file" -w '%{http_code}' "$url" > "$status_file"
else
curl -sS -o "$body_file" -w '%{http_code}' "$url" > "$status_file"
fi
}
assert_live_endpoint() {
local example="$1"
probe_endpoint "$example"
local body_file="$LOG_DIR/${example//\//_}-endpoint-body.txt"
local status
status="$(cat "$LOG_DIR/${example//\//_}-endpoint-status.txt")"
if [ "$status" != "200" ]; then
LAST_ERROR_SUMMARY="live endpoint check failed with HTTP $status"
echo "[$example] live endpoint check failed with HTTP $status"
cat "$body_file"
return 1
fi
if grep -Fq 'function unreachable' "$body_file"; then
LAST_ERROR_SUMMARY="live endpoint returned function unreachable"
echo "[$example] live endpoint check returned unreachable function"
cat "$body_file"
return 1
fi
}
assert_destroyed_endpoint() {
local example="$1"
probe_endpoint "$example"
local body_file="$LOG_DIR/${example//\//_}-endpoint-body.txt"
local status
status="$(cat "$LOG_DIR/${example//\//_}-endpoint-status.txt")"
if [ "$status" = "404" ] || [ "$status" = "000" ]; then
return 0
fi
if grep -Eiq 'not found|404 page not found' "$body_file"; then
return 0
fi
if [ "$status" = "502" ] && grep -Fq 'function unreachable' "$body_file"; then
LAST_ERROR_SUMMARY="route cleanup bug: public endpoint still exists but backend is already gone (HTTP 502 function unreachable)"
echo "[$example] route still exists after destroy, but backend is already gone (HTTP 502 function unreachable)"
cat "$body_file"
return 1
fi
LAST_ERROR_SUMMARY="endpoint still responds after destroy with HTTP $status"
echo "[$example] endpoint still responds after destroy with HTTP $status"
cat "$body_file"
return 1
}
wait_for_destroyed_endpoint() {
local example="$1"
local attempts=24
local sleep_sec=5
local try=1
while [ "$try" -le "$attempts" ]; do
if assert_destroyed_endpoint "$example"; then
echo "[$example] endpoint disappeared after destroy"
return 0
fi
echo "[$example] endpoint still present after destroy, waiting (${try}/${attempts})"
try=$((try + 1))
sleep "$sleep_sec"
done
echo "[$example] endpoint did not disappear after destroy within $((attempts * sleep_sec))s"
if [ -z "$LAST_ERROR_SUMMARY" ]; then
LAST_ERROR_SUMMARY="endpoint remained reachable for more than $((attempts * sleep_sec))s after destroy"
else
LAST_ERROR_SUMMARY="$LAST_ERROR_SUMMARY; endpoint was still published after $((attempts * sleep_sec))s"
fi
return 1
}
backup_and_modify() {
local example="$1"
case "$example" in
hello-node)
cp "$ROOT_DIR/$example/http.tf" "$ROOT_DIR/$example/http.tf.copilot.bak"
perl -0pi -e 's/enabled\s+=\s+true/enabled = false/' "$ROOT_DIR/$example/http.tf"
;;
simple-node)
cp "$ROOT_DIR/$example/time-display.tf" "$ROOT_DIR/$example/time-display.tf.copilot.bak"
perl -0pi -e 's/memory_mb\s+=\s+64/memory_mb = 96/' "$ROOT_DIR/$example/time-display.tf"
;;
simple-python)
cp "$ROOT_DIR/$example/time-display.tf" "$ROOT_DIR/$example/time-display.tf.copilot.bak"
perl -0pi -e 's/memory_mb\s+=\s+64/memory_mb = 96/' "$ROOT_DIR/$example/time-display.tf"
;;
notes-python)
cp "$ROOT_DIR/$example/notes-list.tf" "$ROOT_DIR/$example/notes-list.tf.copilot.bak"
perl -0pi -e 's/memory_mb\s+=\s+128/memory_mb = 160/' "$ROOT_DIR/$example/notes-list.tf"
;;
esac
}
restore_modified_files() {
local example="$1"
case "$example" in
hello-node)
mv "$ROOT_DIR/$example/http.tf.copilot.bak" "$ROOT_DIR/$example/http.tf"
;;
simple-node)
mv "$ROOT_DIR/$example/time-display.tf.copilot.bak" "$ROOT_DIR/$example/time-display.tf"
;;
simple-python)
mv "$ROOT_DIR/$example/time-display.tf.copilot.bak" "$ROOT_DIR/$example/time-display.tf"
;;
notes-python)
mv "$ROOT_DIR/$example/notes-list.tf.copilot.bak" "$ROOT_DIR/$example/notes-list.tf"
;;
esac
}
run_example() {
local example="$1"
echo
echo "==== $example ===="
clean_local_artifacts "$example"
run_step "$example" "terraform init" retry_tf "$example" "terraform init" terraform init -input=false -no-color
run_step "$example" "terraform apply clean" retry_tf "$example" "terraform apply clean" terraform apply -auto-approve -input=false -no-color
record_preexisting_if_needed "$example"
run_step "$example" "endpoint check after clean apply" assert_live_endpoint "$example"
run_step "$example" "terraform destroy clean" retry_tf "$example" "terraform destroy clean" terraform destroy -auto-approve -input=false -no-color
run_step "$example" "endpoint cleanup after clean destroy" wait_for_destroyed_endpoint "$example"
run_step "$example" "terraform apply second" retry_tf "$example" "terraform apply second" terraform apply -auto-approve -input=false -no-color
run_step "$example" "endpoint check after second apply" assert_live_endpoint "$example"
backup_and_modify "$example"
run_step "$example" "terraform apply modified" retry_tf "$example" "terraform apply modified" terraform apply -auto-approve -input=false -no-color
restore_modified_files "$example"
run_step "$example" "terraform destroy final" retry_tf "$example" "terraform destroy final" terraform destroy -auto-approve -input=false -no-color
run_step "$example" "endpoint cleanup after final destroy" wait_for_destroyed_endpoint "$example"
clean_local_artifacts "$example"
}
main() {
local example
for example in "${EXAMPLES[@]}"; do
run_example "$example"
done
if [ "${#PREEXISTING_EXAMPLES[@]}" -gt 0 ]; then
echo
echo "Preexisting remote resources were detected on first apply for: ${PREEXISTING_EXAMPLES[*]}"
exit 2
fi
echo
echo "All Terraform example lifecycles completed successfully."
}
main "$@"
+3 -3
View File
@@ -19,13 +19,13 @@ terraform {
required_providers {
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.13"
version = "~> 0.1.18"
}
}
}
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
endpoint = "https://sless.kube5s.ru"
token = var.token
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
}
@@ -0,0 +1 @@
token = "замени на Nubes API token" и переименуй файл в terraform.tfvars
+3 -3
View File
@@ -18,13 +18,13 @@ terraform {
required_providers {
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.13"
version = "~> 0.1.18"
}
}
}
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
endpoint = "https://sless.kube5s.ru"
token = var.token
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
}
@@ -0,0 +1 @@
token = "замени на Nubes API token" и переименуй файл в terraform.tfvars
+15 -11
View File
@@ -1,10 +1,13 @@
#!/usr/bin/env bash
# Изменено: 2026-03-07
# Создаёт k8s Secret sless-registry-auth с DockerHub кредами для kaniko.
# Изменено: 2026-03-11
# Создаёт k8s Secret sless-registry-auth с кредами Harbor для kaniko.
# Kaniko монтирует этот secret как /kaniko/.docker/config.json для push образов.
# Отдельно от HARBOR_PASS в operator-secret: этот secret — для kaniko (push),
# HARBOR_PASS — для Harbor API (создание проектов через EnsureProject).
#
# Использование:
# DOCKER_HUB_USER=naeel DOCKER_HUB_TOKEN=<token> ./hack/create-registry-secret.sh
# HARBOR_USER=admin HARBOR_PASS=<password> REGISTRY=pearlharbor.registryk8s.services.ngcloud.ru \
# ./hack/create-registry-secret.sh
#
# Требуется: kubectl, авторизованный context с доступом к namespace sless.
@@ -12,21 +15,22 @@ set -euo pipefail
NAMESPACE="sless"
SECRET_NAME="sless-registry-auth"
REGISTRY="${REGISTRY:-pearlharbor.registryk8s.services.ngcloud.ru}"
if [[ -z "${DOCKER_HUB_USER:-}" ]]; then
echo "ERROR: DOCKER_HUB_USER env var is required"
if [[ -z "${HARBOR_USER:-}" ]]; then
echo "ERROR: HARBOR_USER env var is required"
exit 1
fi
if [[ -z "${DOCKER_HUB_TOKEN:-}" ]]; then
echo "ERROR: DOCKER_HUB_TOKEN env var is required (DockerHub access token, not password)"
if [[ -z "${HARBOR_PASS:-}" ]]; then
echo "ERROR: HARBOR_PASS env var is required"
exit 1
fi
kubectl create secret docker-registry "${SECRET_NAME}" \
--docker-username="${DOCKER_HUB_USER}" \
--docker-password="${DOCKER_HUB_TOKEN}" \
--docker-server="https://index.docker.io/v1/" \
--docker-username="${HARBOR_USER}" \
--docker-password="${HARBOR_PASS}" \
--docker-server="${REGISTRY}" \
--namespace="${NAMESPACE}" \
--dry-run=client -o yaml | kubectl apply -f -
echo "OK: secret '${SECRET_NAME}' applied in namespace '${NAMESPACE}'"
echo "OK: secret '${SECRET_NAME}' applied in namespace '${NAMESPACE}' (server: ${REGISTRY})"
+41 -20
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-03-11
// Изменено: 2026-03-18 (добавлены created_at, last_built_at в functionResponse и fnToResponse)
// functions.go — CRUD handlers для Function CRD.
// Принимает JSON, создаёт/обновляет/удаляет k8s ресурсы Function.
// Namespace берётся из URL: /v1/namespaces/{namespace}/functions/{name}
@@ -30,23 +30,29 @@ type functionRequest struct {
// functionResponse — ответ при чтении функции.
type functionResponse struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
Runtime string `json:"runtime"`
Entrypoint string `json:"entrypoint"`
MemoryMB int32 `json:"memory_mb"`
TimeoutSec int32 `json:"timeout_sec"`
Env map[string]string `json:"env_vars"`
S3Bucket string `json:"s3_bucket"`
S3Key string `json:"s3_key"`
Phase slessv1alpha1.FunctionPhase `json:"phase"`
ImageRef string `json:"image_ref"`
Message string `json:"message,omitempty"`
Name string `json:"name"`
Namespace string `json:"namespace"`
Runtime string `json:"runtime"`
Entrypoint string `json:"entrypoint"`
MemoryMB int32 `json:"memory_mb"`
TimeoutSec int32 `json:"timeout_sec"`
Env map[string]string `json:"env_vars"`
S3Bucket string `json:"s3_bucket"`
S3Key string `json:"s3_key"`
Phase slessv1alpha1.FunctionPhase `json:"phase"`
ImageRef string `json:"image_ref"`
Message string `json:"message,omitempty"`
// CreatedAt — время создания CRD объекта (metadata.creationTimestamp).
// Пустое значение = "0001-01-01T00:00:00Z" сериализуется в "", опускаем через omitempty.
CreatedAt string `json:"created_at,omitempty"`
// LastBuiltAt — время последней успешной сборки образа (status.lastBuiltAt).
// nil если сборки ещё не было.
LastBuiltAt string `json:"last_built_at,omitempty"`
}
// fnToResponse конвертирует CRD в ответ API.
func fnToResponse(fn *slessv1alpha1.Function) functionResponse {
return functionResponse{
resp := functionResponse{
Name: fn.Name,
Namespace: fn.Namespace,
Runtime: fn.Spec.Runtime,
@@ -60,6 +66,14 @@ func fnToResponse(fn *slessv1alpha1.Function) functionResponse {
ImageRef: fn.Status.ImageRef,
Message: fn.Status.Message,
}
// creationTimestamp — всегда заполнен k8s, но zero value опускаем.
if !fn.CreationTimestamp.IsZero() {
resp.CreatedAt = fn.CreationTimestamp.UTC().Format("2006-01-02 15:04:05 UTC")
}
if fn.Status.LastBuiltAt != nil && !fn.Status.LastBuiltAt.IsZero() {
resp.LastBuiltAt = fn.Status.LastBuiltAt.UTC().Format("2006-01-02 15:04:05 UTC")
}
return resp
}
// ListFunctions — GET /v1/namespaces/{namespace}/functions
@@ -115,13 +129,20 @@ func (h *Handler) CreateFunction(w http.ResponseWriter, r *http.Request) {
}
if err := h.K8s.Create(r.Context(), fn); err != nil {
if errors.IsAlreadyExists(err) {
// Если существующая функция в статусе Failed (build провалился, terraform не
// добавил её в state) — удаляем её и пересоздаём, иначе клиент получит 409 навсегда.
// IsAlreadyExists может прийти из кеша controller-runtime (split-brain):
// объект удалён из etcd, но кеш informer ещё южив. Делаем uncached Get:
// если реально NotFound — кеш устарел, пересоздаём.
// если существует и фаза Failed — тоже пересоздаём (build провалился, терраформ не добавил в state).
// если существует и фаза Ready/Building — возвращаем 409 (функция реально есть).
existing := &slessv1alpha1.Function{}
if getErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing); getErr == nil &&
existing.Status.Phase == slessv1alpha1.FunctionPhaseFailed {
_ = h.K8s.Delete(r.Context(), existing)
// Создаём заново с теми же параметрами
getErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing)
shouldRecreate := errors.IsNotFound(getErr) ||
(getErr == nil && existing.Status.Phase == slessv1alpha1.FunctionPhaseFailed)
if shouldRecreate {
if getErr == nil {
_ = h.K8s.Delete(r.Context(), existing)
}
// Сбрасываем ResourceVersion — при split-brain etcd считает объект новым
fn.ResourceVersion = ""
if createErr := h.K8s.Create(r.Context(), fn); createErr != nil {
writeJSON(w, http.StatusInternalServerError, errResp(createErr.Error()))
+4 -1
View File
@@ -66,10 +66,13 @@ func (h *Handler) InvokeFunction(w http.ResponseWriter, r *http.Request) {
return
}
// Пробрасываем Content-Type если есть
// Пробрасываем Content-Type и Content-Length если есть.
// Content-Length обязателен: Python BaseHTTPRequestHandler читает тело
// ровно столько байт, сколько указано в заголовке; без него body = пусто.
if ct := r.Header.Get("Content-Type"); ct != "" {
proxyReq.Header.Set("Content-Type", ct)
}
proxyReq.ContentLength = r.ContentLength
resp, err := httpClient.Do(proxyReq)
if err != nil {
+119
View File
@@ -0,0 +1,119 @@
// Создано: 2026-04-25
// source.go — handler для GET /v1/namespaces/{namespace}/functions/{name}/source
// Возвращает список файлов исходного кода функции из tar.gz контекста сборки (S3).
// Dockerfile исключается — он сгенерирован builder-ом, не является кодом пользователя.
// Если код ещё не загружен (S3Key пустой) — возвращает пустой список.
package handler
import (
"archive/tar"
"compress/gzip"
"encoding/base64"
"fmt"
"io"
"net/http"
"strings"
"k8s.io/apimachinery/pkg/api/errors"
"sigs.k8s.io/controller-runtime/pkg/client"
slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1"
)
// sourceFileEntry — один файл из исходного кода функции.
type sourceFileEntry struct {
Name string `json:"name"`
Content string `json:"content"` // UTF-8 текст; бинарные файлы — base64 encoded
Binary bool `json:"binary"` // true если файл бинарный (base64 в content)
}
// GetSource — GET /v1/namespaces/{namespace}/functions/{name}/source
// Скачивает tar.gz контекст сборки из S3 и возвращает пользовательские файлы (без Dockerfile).
func (h *Handler) GetSource(w http.ResponseWriter, r *http.Request) {
ns := namespace(r)
name := pathVar(r, "name")
fn := &slessv1alpha1.Function{}
if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, fn); err != nil {
if errors.IsNotFound(err) {
writeJSON(w, http.StatusNotFound, errResp("function not found"))
return
}
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
// Код ещё не был загружен — возвращаем пустой список без ошибки
if fn.Spec.S3Key == "" {
writeJSON(w, http.StatusOK, []sourceFileEntry{})
return
}
rc, err := h.S3.Download(r.Context(), fn.Spec.S3Key)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errResp("download from S3: "+err.Error()))
return
}
defer rc.Close()
files, err := extractSourceFilesFromTarGz(rc)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errResp("extract source: "+err.Error()))
return
}
writeJSON(w, http.StatusOK, files)
}
// extractSourceFilesFromTarGz читает tar.gz и возвращает пользовательские файлы.
// Dockerfile пропускается — он генерируется builder-ом автоматически.
// Бинарные файлы (содержат null bytes) возвращаются в base64 с binary=true.
func extractSourceFilesFromTarGz(r io.Reader) ([]sourceFileEntry, error) {
gr, err := gzip.NewReader(r)
if err != nil {
return nil, fmt.Errorf("gzip: %w", err)
}
defer gr.Close()
tr := tar.NewReader(gr)
var result []sourceFileEntry
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("tar next: %w", err)
}
// Dockerfile сгенерирован builder-ом — не показываем пользователю
if hdr.Name == "Dockerfile" || hdr.FileInfo().IsDir() {
continue
}
data, err := io.ReadAll(tr)
if err != nil {
return nil, fmt.Errorf("read %s: %w", hdr.Name, err)
}
// Бинарные файлы — base64, текстовые — как есть
content := string(data)
isBinary := strings.ContainsRune(content, '\x00')
if isBinary {
content = base64.StdEncoding.EncodeToString(data)
}
result = append(result, sourceFileEntry{
Name: hdr.Name,
Content: content,
Binary: isBinary,
})
}
if result == nil {
result = []sourceFileEntry{}
}
return result, nil
}
+4 -1
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-03-08
// Изменено: 2026-04-25
// router.go — регистрация всех REST-маршрутов через gorilla/mux.
// Все маршруты защищены Bearer-токеном (middleware.Auth).
// Маршруты сгруппированы по /v1/namespaces/{namespace}/...
@@ -45,6 +45,9 @@ func NewRouter(h *handler.Handler, log *slog.Logger) http.Handler {
// Upload code — принимает zip, генерирует Dockerfile, кладёт tar.gz в S3, запускает сборку
v1.HandleFunc("/namespaces/{namespace}/functions/{name}/upload", h.UploadCode).Methods(http.MethodPost)
// Source code — возвращает файлы из tar.gz контекста сборки (без Dockerfile)
v1.HandleFunc("/namespaces/{namespace}/functions/{name}/source", h.GetSource).Methods(http.MethodGet)
// Triggers CRUD
v1.HandleFunc("/namespaces/{namespace}/triggers", h.ListTriggers).Methods(http.MethodGet)
v1.HandleFunc("/namespaces/{namespace}/triggers", h.CreateTrigger).Methods(http.MethodPost)
+27 -6
View File
@@ -1,9 +1,9 @@
// Изменено: 2026-03-07
// Изменено: 2026-03-11
// Builder — запускает kaniko Job в k8s для сборки Docker образа из кода функции.
// Почему kaniko, а не docker-in-docker (DinD):
// kaniko не требует privileged контейнер, что безопаснее в managed кластере.
// kaniko читает контекст сборки из S3 напрямую.
// Workflow: FunctionController вызывает Build → Job запускается → образ пушится в registry.
// Workflow: FunctionController вызывает Build → EnsureProject (Harbor) → Job запускается → образ пушится в registry.
package builder
@@ -20,17 +20,24 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
)
// Projecter — опциональный интерфейс для реестров с поддержкой проектов (Harbor).
// Если nil — вызов EnsureProject пропускается (Docker Hub, другие реестры без проектов).
type Projecter interface {
EnsureProject(ctx context.Context, name string) error
}
// Builder — управляет сборкой Docker образов через kaniko Jobs в k8s.
type Builder struct {
client client.Client
builderImage string // образ kaniko
registryHost string // куда пушим образ
registryHost string // куда пушим образ (Harbor: host; DockerHub: user/org)
registrySecret string // имя k8s Secret с docker-кредами для kaniko
s3Endpoint string // откуда kaniko берёт код
s3AccessKey string
s3SecretKey string
s3Bucket string
namespace string // namespace где запускаем build Job'ы
namespace string // namespace где запускаем build Job'ы
harborClient Projecter // nil если Harbor не используется
}
// Config — параметры для создания Builder'а.
@@ -43,6 +50,7 @@ type Config struct {
S3SecretKey string
S3Bucket string
Namespace string
HarborClient Projecter // nil — Harbor не используется, EnsureProject пропускается
}
// New создаёт новый Builder.
@@ -57,21 +65,34 @@ func New(c client.Client, cfg Config) *Builder {
s3SecretKey: cfg.S3SecretKey,
s3Bucket: cfg.S3Bucket,
namespace: cfg.Namespace,
harborClient: cfg.HarborClient,
}
}
// ImageRef возвращает полный путь к образу в registry для данной функции и версии.
// Тег = первые 12 символов sha256(s3Key): уникален per build, не меняется при
// повторном reconcile с тем же s3Key, не требует хаков с :latest.
// Формат: {registryHost}/{namespace}/{funcName}:{tag}
//
// Harbor: pearlharbor.registryk8s.services.ngcloud.ru/{ns}/{func}:{tag}
//
// Чтобы сменить реестр — достаточно изменить REGISTRY_HOST в одном месте (ConfigMap).
func (b *Builder) ImageRef(namespace, funcName, s3Key string) string {
h := sha256.Sum256([]byte(s3Key))
tag := fmt.Sprintf("%x", h[:6]) // 12 hex-символов
return fmt.Sprintf("%s/sless-%s-%s:%s", b.registryHost, namespace, funcName, tag)
return fmt.Sprintf("%s/%s/%s:%s", b.registryHost, namespace, funcName, tag)
}
// Build запускает kaniko Job для сборки образа функции.
// Возвращает имя Job'а чтобы контроллер мог следить за его статусом.
func (b *Builder) Build(ctx context.Context, namespace, funcName, s3Key string) (string, error) {
// Идемпотентно создаём Harbor-проект перед push — kaniko сам его не создаёт.
// Пропускаем если HarborClient не задан (Docker Hub и другие реестры без проектов).
if b.harborClient != nil {
if err := b.harborClient.EnsureProject(ctx, namespace); err != nil {
return "", fmt.Errorf("harbor ensure project %q: %w", namespace, err)
}
}
imageRef := b.ImageRef(namespace, funcName, s3Key)
jobName := fmt.Sprintf("build-%s-%s", funcName, time.Now().Format("20060102150405"))
@@ -104,7 +125,7 @@ func (b *Builder) Build(ctx context.Context, namespace, funcName, s3Key string)
Args: []string{
"--context=" + s3ContextURL,
"--destination=" + imageRef,
"--cache=true", // кешируем слои для ускорения повторных сборок
"--no-cache", // без кэша — гарантирует что COPY берёт свежий код из S3
},
Env: []corev1.EnvVar{
{Name: "AWS_ACCESS_KEY_ID", Value: b.s3AccessKey},
+48 -16
View File
@@ -35,16 +35,19 @@ func PrepareContext(zipData []byte, runtime string) (*bytes.Buffer, error) {
// Это знание принадлежит builder-у, не HTTP-хендлеру.
hasRequirements := false // requirements.txt — python3.11: pip install
hasPackageJSON := false // package.json — nodejs20: npm install
hasGoMod := false // go.mod — go1.23: пользователь может дать свой go.mod
for _, f := range zr.File {
switch f.Name {
case "requirements.txt":
hasRequirements = true
case "package.json":
hasPackageJSON = true
case "go.mod":
hasGoMod = true
}
}
dockerfileContent, err := generateDockerfile(runtime, hasRequirements, hasPackageJSON)
dockerfileContent, err := generateDockerfile(runtime, hasRequirements, hasPackageJSON, hasGoMod)
if err != nil {
// Ошибка здесь означает неподдерживаемый runtime — 400 на уровне handler'а.
return nil, err
@@ -63,36 +66,65 @@ func PrepareContext(zipData []byte, runtime string) (*bytes.Buffer, error) {
func runtimeBaseImage(runtime string) (string, error) {
switch runtime {
case "python3.11":
return "naeel/sless-runtime-python3.11:v0.1.1", nil
return "naeel/sless-runtime-python3.11:v0.1.4", nil
case "nodejs20":
return "naeel/sless-runtime-nodejs20:v0.1.2", nil
case "go1.23":
// Go использует builder-образ (содержит golang:1.23 + server.go).
// Конечный образ multi-stage — alpine без компилятора.
return "naeel/sless-runtime-go1.23:v0.1.0", nil
default:
return "", fmt.Errorf("unsupported runtime: %q (supported: python3.11, nodejs20)", runtime)
return "", fmt.Errorf("unsupported runtime: %q (supported: python3.11, nodejs20, go1.23)", runtime)
}
}
// generateDockerfile генерирует Dockerfile для kaniko.
// Базовый образ содержит HTTP-обёртку (server.py / server.js).
// Пользовательский код копируется в /app/function/ поверх базового образа.
// Базовый образ содержит HTTP-обёртку (server.py / server.js) или builder (для Go).
// Пользовательский код копируется в /app/function/ (или /app/handler/ для Go) поверх базового образа.
// Зависимости устанавливаются ПОСЛЕ COPY — чтобы кеш слоёв работал при повторных сборках.
func generateDockerfile(runtime string, hasRequirements bool, hasPackageJSON bool) ([]byte, error) {
func generateDockerfile(runtime string, hasRequirements bool, hasPackageJSON bool, hasGoMod bool) ([]byte, error) {
baseImage, err := runtimeBaseImage(runtime)
if err != nil {
return nil, err
}
content := fmt.Sprintf("FROM %s\nCOPY . /app/function/\n", baseImage)
switch runtime {
case "python3.11":
if hasRequirements {
content += "RUN pip install --no-cache-dir -r /app/function/requirements.txt\n"
}
case "nodejs20":
if hasPackageJSON {
// cd нужен т.к. npm install читает package.json из текущей директории
content += "RUN cd /app/function && npm install --omit=dev\n"
case "go1.23":
// Go: multi-stage build.
// base-образ содержит: server.go (package main) + go.mod (module sless/fn).
// kaniko копирует пользовательский код в handler/ (package handler).
// go build видит: /app/server.go + /app/handler/*.go под одним модулем sless/fn.
// Если у пользователя есть go.mod — он кладётся в /app/handler/go.mod (игнорируется).
// COPY . /app/handler/ — пользовательские файлы лежат в корне tar-контекста (без prefix).
// kaniko не умеет переименовывать пути при COPY, поэтому копируем всё "." в handler/.
content := fmt.Sprintf(
"FROM %s AS builder\n"+
"WORKDIR /app\n"+
"COPY . /app/handler/\n"+
"RUN CGO_ENABLED=0 go build -o /server .\n"+
"FROM alpine:3.20\n"+
"COPY --from=builder /server /server\n"+
"EXPOSE 8080\n"+
"CMD [\"/server\"]\n",
baseImage,
)
return []byte(content), nil
default: // python3.11, nodejs20
content := fmt.Sprintf("FROM %s\nCOPY . /app/function/\n", baseImage)
switch runtime {
case "python3.11":
if hasRequirements {
content += "RUN pip install --no-cache-dir -r /app/function/requirements.txt\n"
}
case "nodejs20":
if hasPackageJSON {
// cd нужен т.к. npm install читает package.json из текущей директории
content += "RUN cd /app/function && npm install --omit=dev\n"
}
}
return []byte(content), nil
}
return []byte(content), nil
}
// zipToTarGz принимает уже распарсенный *zip.Reader и упаковывает содержимое + Dockerfile в tar.gz.
+11 -2
View File
@@ -50,10 +50,14 @@ type Config struct {
// Поле сохранено для совместимости конфигурации.
// Аутентификация происходит в middleware/auth.go через validateJWT().
APIToken string
// Harbor — креды для Harbor API (создание проектов до push образов).
// REGISTRY_HOST уже содержит адрес реестра — дублировать не нужно.
// Если не заданы, EnsureProject пропускается (только DockerHub/без проектов).
HarborUser string
HarborPass string
}
// Load читает конфиг из env переменных.
// Возвращает ошибку если обязательные переменные не заданы.
func Load() (*Config, error) {
cfg := &Config{
APIPort: 8080,
@@ -128,5 +132,10 @@ func Load() (*Config, error) {
// Не required: auth middleware использует validateJWT, а не статический токен.
cfg.APIToken = os.Getenv("SLESS_API_TOKEN")
// Harbor API креды — опциональны.
// Если не заданы, EnsureProject пропускается (push работает через docker-кред в kaniko).
cfg.HarborUser = os.Getenv("HARBOR_USER")
cfg.HarborPass = os.Getenv("HARBOR_PASS")
return cfg, nil
}
+94
View File
@@ -0,0 +1,94 @@
// Изменено: 2026-03-11
// Harbor API клиент — управление проектами (create/ensure) перед push образов.
// Почему здесь: kaniko не создаёт Harbor-проект сам, нужен явный вызов API.
// Все операции идемпотентны - повторный вызов EnsureProject безопасен.
package harbor
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// Client — минимальный HTTP-клиент для Harbor API v2.
// Используется только для создания проектов; пуш образов делает kaniko напрямую.
type Client struct {
baseURL string // https://{registryHost}
user string
password string
http *http.Client
}
// New создаёт Harbor клиент.
// registryHost — хост реестра без схемы (e.g. "pearlharbor.registryk8s.services.ngcloud.ru").
func New(registryHost, user, password string) *Client {
return &Client{
baseURL: "https://" + registryHost,
user: user,
password: password,
http: &http.Client{Timeout: 15 * time.Second},
}
}
// EnsureProject идемпотентно создаёт Harbor проект с именем name (private).
// GET /api/v2.0/projects?name={name} → если не найден → POST /api/v2.0/projects.
// Возвращает nil если проект уже существовал или был успешно создан.
func (c *Client) EnsureProject(ctx context.Context, name string) error {
// Harbor GET projects возвращает список проектов по фильтру name=
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
fmt.Sprintf("%s/api/v2.0/projects?name=%s", c.baseURL, name), nil)
if err != nil {
return fmt.Errorf("harbor: build GET request: %w", err)
}
req.SetBasicAuth(c.user, c.password)
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("harbor: GET project %q: %w", name, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("harbor: unauthorized — проверьте HARBOR_USER и HARBOR_PASS")
}
var projects []struct {
Name string `json:"name"`
}
if err := json.NewDecoder(resp.Body).Decode(&projects); err != nil {
return fmt.Errorf("harbor: decode GET response: %w", err)
}
for _, p := range projects {
if p.Name == name {
return nil // проект уже существует
}
}
// Проект не найден — создаём
body, _ := json.Marshal(map[string]interface{}{
"project_name": name,
"public": false,
})
req, err = http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/api/v2.0/projects", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("harbor: build POST request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth(c.user, c.password)
resp2, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("harbor: POST project %q: %w", name, err)
}
defer resp2.Body.Close()
// 201 Created — успешно; 409 Conflict — гонка (кто-то создал параллельно), оба ок
if resp2.StatusCode != http.StatusCreated && resp2.StatusCode != http.StatusConflict {
return fmt.Errorf("harbor: POST project %q: unexpected status %d", name, resp2.StatusCode)
}
return nil
}
+15
View File
@@ -31,6 +31,7 @@ import (
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/api/handler"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/builder"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/config"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/harbor"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/postgres"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/s3"
//+kubebuilder:scaffold:imports
@@ -118,6 +119,17 @@ func main() {
}
// Builder использует k8s client из manager'а
// harborClient — nil если HARBOR_USER/HARBOR_PASS не заданы (EnsureProject пропускается).
var harborClient *harbor.Client
var harborProjecter builder.Projecter
if cfg.HarborUser != "" && cfg.HarborPass != "" {
harborClient = harbor.New(cfg.RegistryHost, cfg.HarborUser, cfg.HarborPass)
harborProjecter = harborClient
log.Info("harbor client configured", "host", cfg.RegistryHost)
} else {
log.Info("harbor client disabled (HARBOR_USER/HARBOR_PASS not set)")
}
bldr := builder.New(mgr.GetClient(), builder.Config{
BuilderImage: cfg.BuilderImage,
RegistryHost: cfg.RegistryHost,
@@ -127,14 +139,17 @@ func main() {
S3SecretKey: cfg.S3SecretKey,
S3Bucket: cfg.S3Bucket,
Namespace: "sless",
HarborClient: harborProjecter,
})
if err = (&controllers.FunctionReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Builder: bldr,
KubeClient: kubernetes.NewForConfigOrDie(mgr.GetConfig()),
RegistrySecret: cfg.RegistrySecret,
OperatorNamespace: "sless",
HarborClient: harborClient,
}).SetupWithManager(mgr); err != nil {
log.Error("unable to create controller", "controller", "Function", "err", err)
os.Exit(1)
+33
View File
@@ -0,0 +1,33 @@
# Создано: 2026-03-11
# Base builder image для Go 1.23 serverless функций.
# Это BUILDER-образ (не runtime): содержит Go компилятор + server.go.
# server.go — HTTP-wrapper + job-runner, компилируется ВМЕСТЕ с кодом пользователя.
#
# kaniko делает multi-stage build:
# Stage 1: golang:1.23-alpine → COPY server.go + handler/ → go build → /server
# Stage 2: FROM alpine → COPY /server → минимальный финальный образ.
#
# Почему builder-образ, а не runtime: Go требует статической компиляции — нельзя
# "подключить" пользовательский код динамически как в Python/Node.
FROM golang:1.23-alpine AS builder
WORKDIR /app
# server.go — точка входа (main package), вызывает Handler() из пользовательского кода.
COPY server.go /app/server.go
# Код пользователя kaniko копирует в /app/handler/
COPY handler/ /app/handler/
# Статическая сборка (без libc) — работает в alpine-контейнере
RUN CGO_ENABLED=0 go build -o /server /app/server.go
# Финальный минимальный образ
FROM alpine:3.20
COPY --from=builder /server /server
EXPOSE 8080
CMD ["/server"]
+3
View File
@@ -0,0 +1,3 @@
module sless/fn
go 1.23
+86
View File
@@ -0,0 +1,86 @@
// Создано: 2026-03-11
// HTTP-обёртка для serverless функций на Go 1.23.
// Компилируется kaniko ВМЕСТЕ с пользовательским кодом (package handler).
//
// Интерфейс пользователя — файл handler.go:
//
// package handler
//
// func Handle(event map[string]interface{}) interface{} {
// return map[string]interface{}{"hello": event["name"]}
// }
//
// SLESS_MODE=job: разово вызвать Handle(event) → вывести JSON → выйти (для FunctionJob).
// По умолчанию: HTTP-сервер, каждый запрос → Handle(event) → JSON ответ.
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"sless/fn/handler"
)
func main() {
mode := os.Getenv("SLESS_MODE")
// job-runner: разово вызвать Handle(event), вывести результат в JSON и выйти.
if mode == "job" {
eventJSON := os.Getenv("SLESS_EVENT")
if eventJSON == "" {
eventJSON = "{}"
}
var event map[string]interface{}
if err := json.Unmarshal([]byte(eventJSON), &event); err != nil {
event = map[string]interface{}{}
}
result := handler.Handle(event)
out, _ := json.Marshal(result)
fmt.Println(string(out))
return
}
// HTTP-сервер
port := "8080"
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"status":"ok"}`)
})
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
event := map[string]interface{}{}
if r.Method == http.MethodPost || r.Method == http.MethodPut {
body, err := io.ReadAll(r.Body)
if err == nil && len(body) > 0 {
_ = json.Unmarshal(body, &event)
}
}
event["_path"] = r.URL.Path
event["_method"] = r.Method
if q := r.URL.Query(); len(q) > 0 {
qmap := map[string]interface{}{}
for k, v := range q {
if len(v) == 1 {
qmap[k] = v[0]
} else {
qmap[k] = v
}
}
event["_query"] = qmap
}
result := handler.Handle(event)
out, err := json.Marshal(result)
if err != nil {
http.Error(w, `{"error":"marshal failed"}`, 500)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(out)
})
log.Printf("sless runtime (go1.23) listening on :%s", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
+50 -7
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# Изменено: 2026-03-09
# Изменено: 2026-03-18 (string return → text/plain без json.dumps)
# HTTP-обёртка для serverless функций на Python 3.11.
# Загружает модуль из SLESS_ENTRYPOINT или handler.py по умолчанию.
# Формат SLESS_ENTRYPOINT: "module_name.func_name" (например: handler.handle)
@@ -56,6 +56,8 @@ class FunctionHandler(BaseHTTPRequestHandler):
event['_path'] = parsed.path
event['_query'] = query
event['_method'] = self.command
# Accept заголовок — позволяет функции различать браузер и curl/API.
event['_accept'] = self.headers.get('Accept', '')
return event
def do_GET(self):
@@ -65,22 +67,63 @@ class FunctionHandler(BaseHTTPRequestHandler):
event = self._parse_request_meta({})
self._respond(200, _handle(event))
def do_POST(self):
def _handle_with_body(self):
# Общий обработчик для методов с телом (POST, PUT, PATCH, DELETE и др.)
# Читаем тело только если Content-Length > 0, иначе передаём пустой event.
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length)
body = self.rfile.read(length) if length > 0 else b""
try:
event = json.loads(body) if body else {}
except json.JSONDecodeError:
# Если тело не JSON — передаём как строку, не ломаем вызов
event = {"body": body.decode("utf-8", errors="replace")}
event = self._parse_request_meta(event)
result = _handle(event)
self._respond(200, result)
self._respond(200, _handle(event))
def do_POST(self):
self._handle_with_body()
# PUT, DELETE, PATCH — передаём в функцию как обычные вызовы.
# event['_method'] позволяет функции роутить по методу внутри.
do_PUT = _handle_with_body
do_DELETE = _handle_with_body
do_PATCH = _handle_with_body
def do_HEAD(self):
# HEAD: те же заголовки что и GET, но без тела (HTTP-стандарт).
if self.path == "/health":
result = {"status": "ok"}
else:
result = _handle(self._parse_request_meta({}))
body = json.dumps(result).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
# Тело не отправляем — семантика HEAD
def send_error(self, code, message=None, explain=None):
# Переопределяем дефолтный HTML-ответ на JSON — пользователь всегда получает JSON.
body = json.dumps({"error": message or f"HTTP {code}", "code": code}).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _respond(self, status, data):
body = json.dumps(data).encode("utf-8")
# Если функция вернула строку:
# начинается с '<' → HTML (text/html) — для страниц с формами.
# иначе → text/plain — для человекочитаемого текста.
# Позволяет функциям возвращать HTML напрямую без изменений runtime-образа.
if isinstance(data, str):
body = data.encode("utf-8")
ctype = "text/html; charset=utf-8" if data.lstrip().startswith("<") else "text/plain; charset=utf-8"
else:
body = json.dumps(data).encode("utf-8")
ctype = "application/json"
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
+14
View File
@@ -0,0 +1,14 @@
# 2026-04-25 (добавлен index.html — встраивается через go:embed)
# Dockerfile для sless-funcs-service.
# Многоэтапная сборка: Go → alpine (минимальный образ).
FROM golang:1.23-alpine AS builder
WORKDIR /build
COPY go.mod main.go index.html ./
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o funcs-service .
FROM alpine:3.20
RUN apk add --no-cache ca-certificates
COPY --from=builder /build/funcs-service /funcs-service
EXPOSE 8090
ENTRYPOINT ["/funcs-service"]
+107
View File
@@ -0,0 +1,107 @@
# 2026-03-18
# funcs-service.yaml — глобальный сервис листинга функций для всех пользователей.
# Развёртывается ОДИН РАЗ в namespace sless рядом с оператором.
# Доступен по: https://sless.kube5s.ru/funcs (с Bearer токеном пользователя)
#
# Обновить образ и применить:
# docker push naeel/sless-funcs-service:v0.1.0
# kubectl apply -f deployments/k8s/funcs-service.yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: sless-funcs-service
namespace: sless
labels:
app: sless-funcs-service
spec:
replicas: 1
selector:
matchLabels:
app: sless-funcs-service
template:
metadata:
labels:
app: sless-funcs-service
spec:
containers:
- name: funcs
image: naeel/sless-funcs-service:v0.1.3
ports:
- containerPort: 8090
env:
- name: SLESS_OPERATOR_URL
value: "http://sless-operator.sless.svc.cluster.local:9090"
- name: SLESS_EXTERNAL_URL
value: "https://sless.kube5s.ru"
# Системные функции, скрытые из листинга
- name: SLESS_EXCLUDE
value: "event-writer,event-monitor,event-cleaner"
# Токен сервиса задаётся через kubectl set env или Secret — не хранится в репо
# kubectl set env deployment/sless-funcs-service -n sless SLESS_SERVICE_TOKEN="$(cat secrets/test.token)"
- name: PORT
value: "8090"
livenessProbe:
httpGet:
path: /health
port: 8090
initialDelaySeconds: 5
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 8090
initialDelaySeconds: 3
periodSeconds: 10
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
cpu: 100m
memory: 64Mi
---
apiVersion: v1
kind: Service
metadata:
name: sless-funcs-service
namespace: sless
spec:
selector:
app: sless-funcs-service
ports:
- port: 8090
targetPort: 8090
---
# Отдельный Ingress для /funcs — nginx выбирает более специфичный путь перед /
# Без rewrite: сервис сам обрабатывает /funcs path
# TLS-сертификат sless-operator-tls уже управляется cert-manager через ingress оператора;
# здесь только ссылаемся на существующий секрет без аннотации cert-manager.io/cluster-issuer.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: sless-funcs-ingress
namespace: sless
annotations:
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
rules:
- host: sless.kube5s.ru
http:
paths:
- path: /funcs
pathType: Prefix
backend:
service:
name: sless-funcs-service
port:
number: 8090
tls:
- hosts:
- sless.kube5s.ru
secretName: sless-operator-tls
+3
View File
@@ -0,0 +1,3 @@
module sless/funcs-service
go 1.23
+492
View File
@@ -0,0 +1,492 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>sless console</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
background: #0d1117;
color: #e6edf3;
min-height: 100vh;
}
a {
color: inherit;
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #161b22;
}
::-webkit-scrollbar-thumb {
background: #30363d;
border-radius: 4px;
}
header {
padding: 14px 24px;
border-bottom: 1px solid #30363d;
display: flex;
align-items: center;
gap: 12px;
background: #161b22;
position: sticky;
top: 0;
z-index: 10;
}
.logo {
font-weight: 700;
font-size: 1.1rem;
color: #58a6ff;
letter-spacing: -0.3px;
}
.logo-slash {
color: #484f58;
margin: 0 4px;
}
.ns-badge {
background: #1c2128;
border: 1px solid #30363d;
padding: 3px 12px;
border-radius: 20px;
font-family: monospace;
font-size: 0.82rem;
color: #8b949e;
}
.refresh-btn {
margin-left: auto;
background: #21262d;
border: 1px solid #30363d;
color: #c9d1d9;
padding: 6px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 0.82rem;
transition: background 0.1s;
}
.refresh-btn:hover {
background: #30363d;
}
main {
padding: 24px;
max-width: 960px;
margin: 0 auto;
}
.summary {
color: #8b949e;
font-size: 0.82rem;
margin-bottom: 18px;
}
.fn-card {
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
margin-bottom: 10px;
overflow: hidden;
transition: border-color 0.15s;
}
.fn-card:hover {
border-color: #484f58;
}
.fn-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
cursor: pointer;
user-select: none;
flex-wrap: wrap;
row-gap: 6px;
}
.fn-header:hover {
background: rgba(255, 255, 255, 0.03);
}
.fn-name {
font-weight: 600;
font-size: 0.92rem;
color: #e6edf3;
}
.badge {
display: inline-block;
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 12px;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.badge-runtime {
background: #21262d;
border: 1px solid #30363d;
color: #8b949e;
}
.badge-Ready {
background: #1a4731;
border: 1px solid #1f6132;
color: #3fb950;
}
.badge-Building {
background: #382a0a;
border: 1px solid #5a4212;
color: #d29922;
}
.badge-Pending {
background: #21262d;
border: 1px solid #30363d;
color: #8b949e;
}
.badge-Failed {
background: #3d1a1a;
border: 1px solid #6b1a1a;
color: #f85149;
}
.fn-url {
font-size: 0.76rem;
color: #58a6ff;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 280px;
flex-shrink: 1;
}
.fn-url:hover {
text-decoration: underline;
}
.fn-cron {
font-size: 0.76rem;
color: #8b949e;
font-family: monospace;
}
.fn-msg {
font-size: 0.73rem;
color: #f85149;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.spacer {
flex: 1 0 0;
min-width: 4px;
}
.trigger-btn {
flex-shrink: 0;
padding: 4px 12px;
border-radius: 6px;
border: 1px solid transparent;
cursor: pointer;
font-size: 0.76rem;
font-weight: 500;
line-height: 1.5;
transition: opacity 0.1s, background 0.1s;
}
.trigger-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.trigger-btn.state-on {
background: #21262d;
border-color: #1f6132;
color: #3fb950;
}
.trigger-btn.state-on:hover:not(:disabled) {
background: #1a4731;
}
.trigger-btn.state-off {
background: #21262d;
border-color: #6b1a1a;
color: #f85149;
}
.trigger-btn.state-off:hover:not(:disabled) {
background: #3d1a1a;
}
.expand-icon {
color: #8b949e;
flex-shrink: 0;
font-size: 0.65rem;
transition: transform 0.18s;
}
.fn-body {
border-top: 1px solid #30363d;
}
.src-msg {
padding: 14px 16px;
color: #8b949e;
font-size: 0.85rem;
font-style: italic;
}
.file-block {
border-bottom: 1px solid #21262d;
}
.file-block:last-child {
border-bottom: none;
}
.file-header {
padding: 6px 16px;
background: #0d1117;
font-family: monospace;
font-size: 0.76rem;
color: #8b949e;
border-bottom: 1px solid #21262d;
}
.file-block pre {
margin: 0;
}
.file-block pre code.hljs {
border-radius: 0 !important;
font-size: 0.78rem !important;
padding: 14px 16px !important;
line-height: 1.5 !important;
}
.empty-state {
text-align: center;
padding: 64px 24px;
color: #484f58;
font-size: 0.9rem;
}
</style>
</head>
<body>
<header>
<span class="logo">sless</span>
<span class="logo-slash">/</span>
<span class="ns-badge" id="ns-label">…</span>
<button class="refresh-btn" onclick="location.reload()">↻ обновить</button>
</header>
<main>
<p class="summary" id="summary"></p>
<div id="fn-list"></div>
</main>
<script id="fndata" type="application/json">__PAGE_DATA__</script>
<script>
(function () {
'use strict';
var raw = document.getElementById('fndata').textContent;
var data = JSON.parse(raw);
var ns = data.namespace;
var extURL = data.externalURL || '';
var fns = data.functions || [];
var byFn = data.triggersByFn || {};
document.getElementById('ns-label').textContent = ns;
document.getElementById('summary').textContent = 'Функций: ' + fns.length;
var list = document.getElementById('fn-list');
if (fns.length === 0) {
var empty = $el('div', 'empty-state');
empty.textContent = 'Нет функций в этом namespace';
list.appendChild(empty);
}
for (var i = 0; i < fns.length; i++) {
list.appendChild(buildCard(fns[i], byFn[fns[i].name] || []));
}
function buildCard(fn, triggers) {
var card = $el('div', 'fn-card');
var hdr = $el('div', 'fn-header');
var body = $el('div', 'fn-body');
body.style.display = 'none';
$txt(hdr, 'span', 'fn-name', fn.name);
$txt(hdr, 'span', 'badge badge-runtime', fn.runtime);
$txt(hdr, 'span', 'badge badge-' + fn.phase, fn.phase);
for (var t = 0; t < triggers.length; t++) {
var tr = triggers[t];
if (tr.type === 'http') {
var url = extURL ? extURL + '/fn/' + ns + '/' + fn.name : (tr.url || '');
if (url) {
var a = document.createElement('a');
a.className = 'fn-url';
a.href = url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.textContent = url;
a.addEventListener('click', function (e) { e.stopPropagation(); });
hdr.appendChild(a);
}
hdr.appendChild(makeTriggerBtn(tr, ns));
} else if (tr.type === 'cron') {
$txt(hdr, 'span', 'fn-cron', 'cron: ' + tr.schedule);
hdr.appendChild(makeTriggerBtn(tr, ns));
}
}
if (fn.message) $txt(hdr, 'span', 'fn-msg', fn.message);
hdr.appendChild($el('span', 'spacer'));
var icon = $txt(hdr, 'span', 'expand-icon', '▶');
var loaded = false;
hdr.addEventListener('click', function () {
if (body.style.display === 'none') {
body.style.display = 'block';
icon.style.transform = 'rotate(90deg)';
if (!loaded) {
loaded = true;
loadSource(body, ns, fn.name);
}
} else {
body.style.display = 'none';
icon.style.transform = '';
}
});
card.appendChild(hdr);
card.appendChild(body);
return card;
}
function makeTriggerBtn(tr, ns) {
var btn = $el('button', 'trigger-btn ' + (tr.enabled ? 'state-on' : 'state-off'));
btn.textContent = tr.enabled ? '■ стоп' : '▶ запуск';
btn.title = tr.name;
btn.addEventListener('click', function (e) {
e.stopPropagation();
btn.disabled = true;
var newVal = !tr.enabled;
fetch('/funcs/' + ns + '/triggers/' + tr.name, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: newVal })
}).then(function (r) {
if (r.ok) {
tr.enabled = newVal;
btn.className = 'trigger-btn ' + (newVal ? 'state-on' : 'state-off');
btn.textContent = newVal ? '■ стоп' : '▶ запуск';
} else {
return r.text().then(function (t) { alert('Ошибка: ' + t); });
}
}).catch(function (err) {
alert('Сетевая ошибка: ' + err);
}).finally(function () {
btn.disabled = false;
});
});
return btn;
}
function loadSource(body, ns, fnName) {
var msg = $el('div', 'src-msg');
msg.textContent = 'Загрузка кода…';
body.appendChild(msg);
fetch('/funcs/' + ns + '/source/' + fnName)
.then(function (r) {
body.removeChild(msg);
if (!r.ok) {
var e = $el('div', 'src-msg');
e.textContent = 'Ошибка ' + r.status;
body.appendChild(e);
return;
}
return r.json().then(function (files) {
var shown = files.filter(function (f) { return !f.binary; });
if (shown.length === 0) {
var e = $el('div', 'src-msg');
e.textContent = 'Код не загружен';
body.appendChild(e);
return;
}
for (var i = 0; i < shown.length; i++) {
var f = shown[i];
var block = $el('div', 'file-block');
var fhdr = $el('div', 'file-header');
fhdr.textContent = f.name;
var pre = document.createElement('pre');
var code = document.createElement('code');
code.className = 'language-' + detectLang(f.name);
code.textContent = f.content;
pre.appendChild(code);
block.appendChild(fhdr);
block.appendChild(pre);
body.appendChild(block);
}
hljs.highlightAll();
});
})
.catch(function (err) {
body.innerHTML = '';
var e = $el('div', 'src-msg');
e.textContent = 'Ошибка: ' + err;
body.appendChild(e);
});
}
function detectLang(name) {
var ext = name.split('.').pop().toLowerCase();
var m = {
py: 'python', js: 'javascript', ts: 'typescript', go: 'go',
sh: 'bash', bash: 'bash', yml: 'yaml', yaml: 'yaml',
json: 'json', md: 'markdown', txt: 'plaintext', toml: 'toml',
rs: 'rust', rb: 'ruby', java: 'java', cpp: 'cpp', c: 'c',
cs: 'csharp', php: 'php', html: 'html', css: 'css'
};
return m[ext] || 'plaintext';
}
function $el(tag, cls) { var e = document.createElement(tag); if (cls) e.className = cls; return e; }
function $txt(p, tag, cls, text) { var e = $el(tag, cls); e.textContent = text; p.appendChild(e); return e; }
})();
</script>
</body>
</html>
+499
View File
@@ -0,0 +1,499 @@
// Изменено: 2026-04-25 (добавлена HTML web-консоль, proxy endpoints для source/triggers)
// main.go — глобальный HTTP сервис листинга функций пользователя.
// Развёрнут ОДИН РАЗ в namespace sless; работает для ВСЕХ пользователей.
// Не связан с terraform — деплоится манифестом deployments/k8s/funcs-service.yaml.
//
// Маршруты:
// GET /funcs — usage hint (без токена)
// GET /funcs?token=<jwt> — листинг через JWT токен
// GET /funcs/<namespace> — листинг (Accept: text/html → HTML, иначе plain text)
// GET /funcs/<namespace>/source/<fn> — прокси к оператору: файлы исходного кода (JSON)
// PATCH /funcs/<namespace>/triggers/<n> — прокси к оператору: enable/disable триггера
// GET /health — liveness/readiness probe
//
// Env vars:
// SLESS_OPERATOR_URL — URL оператора внутри кластера (default: http://sless-operator.sless.svc.cluster.local:9090)
// SLESS_EXTERNAL_URL — публичный базовый URL для корректных ссылок на функции
// SLESS_EXCLUDE — comma-separated список имён функций, скрытых из листинга
// SLESS_SERVICE_TOKEN — токен сервиса для запросов к оператору (для /funcs/<namespace> без токена юзера)
// PORT — порт сервера (default: 8090)
package main
import (
"bytes"
"crypto/sha256"
_ "embed"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"regexp"
"sort"
"strings"
)
//go:embed index.html
var htmlPageTemplate string
// fnResponse — ответ /v1/namespaces/{ns}/functions (подмножество полей оператора)
type fnResponse struct {
Name string `json:"name"`
Runtime string `json:"runtime"`
Phase string `json:"phase"`
Message string `json:"message"`
CreatedAt string `json:"created_at"`
LastBuiltAt string `json:"last_built_at"`
}
// trResponse — ответ /v1/namespaces/{ns}/triggers
type trResponse struct {
Name string `json:"name"`
Type string `json:"type"`
FunctionRef string `json:"function"`
Schedule string `json:"schedule"`
Enabled bool `json:"enabled"`
Active bool `json:"active"`
URL string `json:"url"`
}
// pageData — структура данных для HTML-шаблона web-консоли.
type pageData struct {
Namespace string `json:"namespace"`
ExternalURL string `json:"externalURL"`
Functions []fnResponse `json:"functions"`
TriggersByFn map[string][]trResponse `json:"triggersByFn"`
}
// nsRegex — базовая валидация kubernetes namespace (ловеркейс + hyphens).
// Защита от path traversal при подстановке в URL запросов к оператору.
var nsRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$`)
func main() {
operatorURL := strings.TrimRight(env("SLESS_OPERATOR_URL", "http://sless-operator.sless.svc.cluster.local:9090"), "/")
externalURL := strings.TrimRight(env("SLESS_EXTERNAL_URL", ""), "/")
port := env("PORT", "8090")
serviceToken := os.Getenv("SLESS_SERVICE_TOKEN")
exclude := map[string]bool{}
for _, n := range strings.Split(os.Getenv("SLESS_EXCLUDE"), ",") {
if n = strings.TrimSpace(n); n != "" {
exclude[n] = true
}
}
// /funcs (точное совпадение) — namespace из JWT токена (?token= или Authorization header)
http.HandleFunc("/funcs", handleFuncsToken(operatorURL, externalURL, exclude))
// /funcs/ — namespace в URL пути + sub-paths (source/triggers proxy)
http.HandleFunc("/funcs/", handleFuncsNS(operatorURL, externalURL, serviceToken, exclude))
// /health — для liveness/readiness probe (без auth)
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintln(w, "ok")
})
log.Printf("sless-funcs-service listening on :%s (operator: %s)", port, operatorURL)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
// handleFuncsToken обрабатывает GET /funcs с JWT токеном (?token= или Authorization header).
func handleFuncsToken(operatorURL, externalURL string, exclude map[string]bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
}
if token == "" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintln(w, "Укажите namespace в URL или передайте токен:")
fmt.Fprintln(w, "")
fmt.Fprintf(w, " %s/funcs/<namespace>\n", externalURL)
fmt.Fprintf(w, " %s/funcs?token=<jwt>\n", externalURL)
return
}
sub, err := subFromJWT(token)
if err != nil {
http.Error(w, fmt.Sprintf("invalid token: %s\n", err), http.StatusUnauthorized)
return
}
fetchAndRender(w, r, operatorURL, externalURL, "Bearer "+token, namespaceFromSub(sub), exclude)
}
}
// handleFuncsNS обрабатывает все запросы /funcs/{ns}[/sub/path].
//
// Маршруты:
//
// GET /funcs/{ns} — листинг (HTML если Accept: text/html, иначе plain text)
// GET /funcs/{ns}/source/{fn} — прокси: файлы исходного кода функции
// PATCH /funcs/{ns}/triggers/{name} — прокси: enable/disable триггер
func handleFuncsNS(operatorURL, externalURL, serviceToken string, exclude map[string]bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rest := strings.TrimPrefix(r.URL.Path, "/funcs/")
rest = strings.Trim(rest, "/")
if rest == "" {
http.Error(w, "usage: /funcs/<namespace>\n", http.StatusBadRequest)
return
}
// SplitN до 3 частей: [ns], [ns, subtype] или [ns, subtype, resourceName]
parts := strings.SplitN(rest, "/", 3)
ns := parts[0]
if !nsRegex.MatchString(ns) {
http.Error(w, "invalid namespace\n", http.StatusBadRequest)
return
}
if len(parts) == 3 {
switch parts[1] {
case "source":
if r.Method != http.MethodGet {
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
return
}
proxySourceGet(w, r, operatorURL, serviceToken, ns, parts[2])
return
case "triggers":
if r.Method != http.MethodPatch {
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
return
}
proxyTriggerPatch(w, r, operatorURL, serviceToken, ns, parts[2])
return
}
}
if len(parts) > 1 {
http.NotFound(w, r)
return
}
// GET /funcs/{ns} — листинг с авторизацией через serviceToken
authHeader := ""
if serviceToken != "" {
authHeader = "Bearer " + serviceToken
}
fetchAndRender(w, r, operatorURL, externalURL, authHeader, ns, exclude)
}
}
// fetchAndRender загружает данные из оператора и отдаёт ответ: HTML (браузер) или plain text (curl).
func fetchAndRender(w http.ResponseWriter, r *http.Request, operatorURL, externalURL, authHeader, ns string, exclude map[string]bool) {
fns, err := apiGet[[]fnResponse](operatorURL+"/v1/namespaces/"+ns+"/functions", authHeader)
if err != nil {
http.Error(w, fmt.Sprintf("operator error (functions): %s\n", err), http.StatusBadGateway)
return
}
trs, err := apiGet[[]trResponse](operatorURL+"/v1/namespaces/"+ns+"/triggers", authHeader)
if err != nil {
http.Error(w, fmt.Sprintf("operator error (triggers): %s\n", err), http.StatusBadGateway)
return
}
if acceptsHTML(r) {
serveHTML(w, ns, externalURL, fns, trs, exclude)
} else {
servePlainText(w, ns, externalURL, fns, trs, exclude)
}
}
// acceptsHTML возвращает true если клиент предпочитает HTML (браузер).
// curl без -H "Accept: text/html" получит plain text — поведение совместимо с v0.1.x.
func acceptsHTML(r *http.Request) bool {
return strings.Contains(r.Header.Get("Accept"), "text/html")
}
// serveHTML рендерит web-консоль.
// Данные встраиваются как JSON в <script type="application/json"> — изолировано от HTML-контекста.
// </script> в данных экранируется как <\/script> (валидный JSON escape).
func serveHTML(w http.ResponseWriter, ns, externalURL string, fns []fnResponse, trs []trResponse, exclude map[string]bool) {
trigIdx := buildTriggerIndex(trs)
filtered := filterAndSort(fns, trigIdx, exclude)
pd := pageData{
Namespace: ns,
ExternalURL: externalURL,
Functions: filtered,
TriggersByFn: trigIdx,
}
raw, _ := json.Marshal(pd)
// Предотвращаем преждевременное закрытие тега <script type="application/json">
safeJSON := strings.ReplaceAll(string(raw), "</", `<\/`)
page := strings.ReplaceAll(htmlPageTemplate, "__PAGE_DATA__", safeJSON)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, page)
}
// servePlainText рендерит human-readable листинг для терминала (curl).
func servePlainText(w http.ResponseWriter, ns, externalURL string, fns []fnResponse, trs []trResponse, exclude map[string]bool) {
trigIdx := buildTriggerIndex(trs)
type entry struct {
fn fnResponse
httpT []trResponse
cronT []trResponse
}
var items []entry
for _, fn := range fns {
if exclude[fn.Name] {
continue
}
var httpT, cronT []trResponse
for _, tr := range trigIdx[fn.Name] {
switch tr.Type {
case "http":
httpT = append(httpT, tr)
case "cron":
cronT = append(cronT, tr)
}
}
items = append(items, entry{fn, httpT, cronT})
}
sort.Slice(items, func(i, j int) bool {
ai := isActive(items[i].fn.Name, trigIdx)
aj := isActive(items[j].fn.Name, trigIdx)
if ai != aj {
return ai
}
return items[i].fn.Name < items[j].fn.Name
})
sep := strings.Repeat("─", 52)
var sb strings.Builder
for _, it := range items {
fn := it.fn
sb.WriteString(sep + "\n")
sb.WriteString(" " + buildComment(fn, it.httpT, it.cronT) + "\n")
sb.WriteString(fmt.Sprintf(" name: %s\n", fn.Name))
sb.WriteString(fmt.Sprintf(" runtime: %s\n", fn.Runtime))
sb.WriteString(fmt.Sprintf(" phase: %s\n", fn.Phase))
if isActive(fn.Name, trigIdx) {
sb.WriteString(" active: да\n")
} else {
sb.WriteString(" active: нет\n")
}
if len(it.httpT) > 0 {
url := it.httpT[0].URL
if externalURL != "" {
url = externalURL + "/fn/" + ns + "/" + fn.Name
}
sb.WriteString(fmt.Sprintf(" url: %s\n", url))
}
if len(it.cronT) > 0 {
sb.WriteString(fmt.Sprintf(" cron: %s\n", it.cronT[0].Schedule))
}
if fn.CreatedAt != "" {
sb.WriteString(fmt.Sprintf(" created: %s\n", fn.CreatedAt))
}
if fn.LastBuiltAt != "" {
sb.WriteString(fmt.Sprintf(" built: %s\n", fn.LastBuiltAt))
}
if fn.Message != "" {
sb.WriteString(fmt.Sprintf(" message: %s\n", fn.Message))
}
}
sb.WriteString(sep + "\n")
sb.WriteString(fmt.Sprintf(" namespace: %s | total: %d\n", ns, len(items)))
sb.WriteString(sep + "\n")
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprint(w, sb.String())
}
// proxySourceGet проксирует GET /funcs/{ns}/source/{fn} → оператор GET /v1/.../source.
// Авторизация — сервисный токен (хранится на сервере, не открывается клиенту).
func proxySourceGet(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, funcName string) {
if !isValidK8sName(funcName) {
http.Error(w, "invalid function name\n", http.StatusBadRequest)
return
}
target := operatorURL + "/v1/namespaces/" + ns + "/functions/" + funcName + "/source"
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target, nil)
if err != nil {
http.Error(w, "internal error\n", http.StatusInternalServerError)
return
}
if serviceToken != "" {
req.Header.Set("Authorization", "Bearer "+serviceToken)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
http.Error(w, fmt.Sprintf("operator error: %s\n", err), http.StatusBadGateway)
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body) //nolint:errcheck
}
// proxyTriggerPatch проксирует PATCH /funcs/{ns}/triggers/{name} → оператор.
// Принимает только поле "enabled" — защита от произвольных изменений с сервисным токеном.
func proxyTriggerPatch(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, triggerName string) {
if !isValidK8sName(triggerName) {
http.Error(w, "invalid trigger name\n", http.StatusBadRequest)
return
}
// Читаем только поле enabled — остальные поля отбрасываем.
var body struct {
Enabled *bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Enabled == nil {
http.Error(w, `{"error":"required: {\"enabled\": true/false}"}`, http.StatusBadRequest)
return
}
reqBody, _ := json.Marshal(map[string]bool{"enabled": *body.Enabled})
target := operatorURL + "/v1/namespaces/" + ns + "/triggers/" + triggerName
opReq, err := http.NewRequestWithContext(r.Context(), http.MethodPatch, target, bytes.NewReader(reqBody))
if err != nil {
http.Error(w, "internal error\n", http.StatusInternalServerError)
return
}
opReq.Header.Set("Content-Type", "application/json")
if serviceToken != "" {
opReq.Header.Set("Authorization", "Bearer "+serviceToken)
}
resp, err := http.DefaultClient.Do(opReq)
if err != nil {
http.Error(w, fmt.Sprintf("operator error: %s\n", err), http.StatusBadGateway)
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body) //nolint:errcheck
}
// buildTriggerIndex строит индекс триггеров по имени функции (FunctionRef).
func buildTriggerIndex(trs []trResponse) map[string][]trResponse {
idx := map[string][]trResponse{}
for _, tr := range trs {
idx[tr.FunctionRef] = append(idx[tr.FunctionRef], tr)
}
return idx
}
// filterAndSort фильтрует функции (exclude) и сортирует: активные вверх, затем по имени.
func filterAndSort(fns []fnResponse, trigIdx map[string][]trResponse, exclude map[string]bool) []fnResponse {
result := make([]fnResponse, 0, len(fns))
for _, fn := range fns {
if !exclude[fn.Name] {
result = append(result, fn)
}
}
sort.Slice(result, func(i, j int) bool {
ai := isActive(result[i].Name, trigIdx)
aj := isActive(result[j].Name, trigIdx)
if ai != aj {
return ai
}
return result[i].Name < result[j].Name
})
return result
}
// isActive возвращает true если хотя бы один триггер функции включён и активен.
func isActive(funcName string, trigIdx map[string][]trResponse) bool {
for _, tr := range trigIdx[funcName] {
if tr.Enabled && tr.Active {
return true
}
}
return false
}
// isValidK8sName базовая проверка имени k8s-ресурса: нет path traversal символов.
func isValidK8sName(s string) bool {
return s != "" && !strings.Contains(s, "/") && !strings.Contains(s, "..") && len(s) <= 253
}
func buildComment(fn fnResponse, httpT, cronT []trResponse) string {
if len(httpT) > 0 {
active := "активна"
if !httpT[0].Active {
active = "неактивна"
}
return fmt.Sprintf("HTTP endpoint (%s) — %s, %s", fn.Runtime, fn.Phase, active)
}
if len(cronT) > 0 {
active := "активна"
if !cronT[0].Active {
active = "неактивна"
}
return fmt.Sprintf("Cron '%s' (%s) — %s, %s", cronT[0].Schedule, fn.Runtime, fn.Phase, active)
}
return fmt.Sprintf("Job/runner без триггера (%s) — %s", fn.Runtime, fn.Phase)
}
// subFromJWT декодирует JWT payload (без проверки подписи) и возвращает sub.
// Подпись не проверяется — trusted perimeter: сервис работает за Ingress.
func subFromJWT(token string) (string, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return "", fmt.Errorf("invalid jwt: expected 3 parts")
}
// base64url без паддинга — добавляем паддинг стандартно
payload := parts[1]
switch len(payload) % 4 {
case 2:
payload += "=="
case 3:
payload += "="
}
data, err := base64.URLEncoding.DecodeString(payload)
if err != nil {
return "", fmt.Errorf("decode payload: %w", err)
}
var claims map[string]any
if err := json.Unmarshal(data, &claims); err != nil {
return "", fmt.Errorf("unmarshal claims: %w", err)
}
sub, ok := claims["sub"].(string)
if !ok || sub == "" {
return "", fmt.Errorf("missing sub claim")
}
return sub, nil
}
// namespaceFromSub — та же логика что в операторе и terraform провайдере.
// SHA256(sub) → первые 8 байт → hex → "sless-{16 hex символов}"
func namespaceFromSub(sub string) string {
hash := sha256.Sum256([]byte(sub))
return fmt.Sprintf("sless-%x", hash[:8])
}
func apiGet[T any](url, authHeader string) (T, error) {
var zero T
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return zero, err
}
req.Header.Set("Authorization", authHeader)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return zero, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return zero, fmt.Errorf("status %d: %s", resp.StatusCode, body)
}
if err := json.Unmarshal(body, &zero); err != nil {
return zero, fmt.Errorf("unmarshal: %w", err)
}
return zero, nil
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+8 -1
View File
@@ -1,4 +1,4 @@
// 2026-03-11
// 2026-03-17 12:20
// client.go — HTTP-клиент для REST API sless оператора.
// Изолирован от terraform-plugin-framework — зависит только от stdlib и net/http.
// Все методы принимают ctx для правильной работы с таймаутами terraform.
@@ -22,6 +22,7 @@ import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
@@ -44,6 +45,9 @@ type Client struct {
Namespace string
}
// ErrJobAlreadyExists возвращается при попытке создать FunctionJob с уже существующим именем.
var ErrJobAlreadyExists = errors.New("job already exists")
// New создаёт клиент.
// - endpoint — базовый URL оператора (без trailing slash), например "https://sless-api.kube5s.ru"
// - token — Bearer JWT-токен облака
@@ -456,6 +460,9 @@ func (c *Client) CreateJob(ctx context.Context, ns string, req JobRequest) (*Job
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusConflict {
return nil, fmt.Errorf("%w: %s", ErrJobAlreadyExists, strings.TrimSpace(string(body)))
}
return nil, fmt.Errorf("create job: status %d: %s", resp.StatusCode, body)
}
var j JobResponse
@@ -3,10 +3,11 @@
//
// Lifecycle:
//
// Create: POST /functions → если code_path задан: upload zip → WaitReady (5 мин)
// Read: GET /functions/{name} → sync state
// Update: PUT /functions/{name} → если code_hash изменился: upload zip → WaitReady
// Delete: DELETE /functions/{name}
// Create: POST /functions → если code_path задан: upload zip → WaitReady (5 мин)
// Read: GET /functions/{name} → sync state
// Update: PUT /functions/{name} → если code_hash изменился: upload zip → WaitReady
// Delete: DELETE /functions/{name}
// ModifyPlan: вычисляет hash source_dir на фазе plan → terraform видит diff при изменении кода
//
// code_hash — пользователь задаёт сам (например filemd5("./handler.zip")).
// Изменение hash → провайдер перезагружает zip и ждёт новой сборки.
@@ -47,6 +48,7 @@ import (
const defaultBuildTimeoutSec = 300
var _ resource.Resource = &FunctionResource{}
var _ resource.ResourceWithModifyPlan = &FunctionResource{}
type FunctionResource struct {
client *client.Client
@@ -97,7 +99,7 @@ func (r *FunctionResource) Schema(_ context.Context, _ resource.SchemaRequest, r
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
stringvalidator.OneOf("nodejs20", "python3.11", "go1.21"),
stringvalidator.OneOf("nodejs20", "python3.11", "go1.23"),
},
},
"entrypoint": schema.StringAttribute{
@@ -344,6 +346,34 @@ func (r *FunctionResource) Delete(ctx context.Context, req resource.DeleteReques
}
}
// ModifyPlan вычисляет hash директории source_dir на фазе terraform plan.
// Без этого terraform не видит изменения файлов кода между apply — потому что
// code_hash Computed и при plan берётся из state. Здесь мы явно пересчитываем
// и записываем актуальный hash в plan → если отличается от state, terraform
// показывает diff и вызывает Update.
func (r *FunctionResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) {
// При destroy plan пустой — ничего не делаем
if req.Plan.Raw.IsNull() {
return
}
var plan FunctionModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
// source_dir не задан — нечего считать
if plan.SourceDir.IsNull() || plan.SourceDir.ValueString() == "" {
return
}
_, hash, err := zipDir(plan.SourceDir.ValueString())
if err != nil {
// Директория может не существовать при первом init — не фейлим plan
return
}
plan.CodeHash = types.StringValue(hash)
resp.Diagnostics.Append(resp.Plan.Set(ctx, plan)...)
}
// fnToModel конвертирует API-ответ + plan (для локальных полей) → state модель.
// plan используется для code_path, code_hash, build_timeout_sec — API их не хранит.
func fnToModel(plan FunctionModel, fn *client.FunctionResponse) FunctionModel {
@@ -1,4 +1,4 @@
// 2026-03-08
// 2026-03-17 12:20
// job_resource.go — Terraform ресурс sless_job.
//
// Lifecycle:
@@ -17,6 +17,7 @@ package resources
import (
"context"
"errors"
"fmt"
"time"
@@ -163,15 +164,28 @@ func (r *JobResource) Create(ctx context.Context, req resource.CreateRequest, re
// run_id=0: создаём FunctionJob в k8s, но оператор не запустит k8s Job.
// Пользователь может поменять run_id > 0 позже чтобы запустить.
runID := plan.RunID.ValueInt64()
_, err := r.client.CreateJob(ctx, ns, client.JobRequest{
createdJob, err := r.client.CreateJob(ctx, ns, client.JobRequest{
Name: plan.Name.ValueString(),
FunctionRef: plan.Function.ValueString(),
EventJSON: eventJSON,
RunID: runID,
})
if err != nil {
resp.Diagnostics.AddError("create job", err.Error())
return
if errors.Is(err, client.ErrJobAlreadyExists) {
existingJob, getErr := r.client.GetJob(ctx, ns, plan.Name.ValueString())
if getErr != nil {
resp.Diagnostics.AddError("create job", fmt.Sprintf("job already exists and get existing failed: %s", getErr.Error()))
return
}
if existingJob == nil {
resp.Diagnostics.AddError("create job", "job already exists but cannot be read right after conflict")
return
}
createdJob = existingJob
} else {
resp.Diagnostics.AddError("create job", err.Error())
return
}
}
// Если RunID=0 — не ждём завершения, пишем state сразу
@@ -187,10 +201,10 @@ func (r *JobResource) Create(ctx context.Context, req resource.CreateRequest, re
EventJSON: plan.EventJSON,
RunID: types.Int64Value(0),
WaitTimeoutSec: waitTimeoutSec,
Phase: types.StringValue("Skipped"),
StartTime: types.StringValue(""),
CompletionTime: types.StringValue(""),
Message: types.StringValue("run_id=0: set run_id>0 to execute"),
Phase: types.StringValue(createdJob.Phase),
StartTime: types.StringValue(createdJob.StartTime),
CompletionTime: types.StringValue(createdJob.CompletionTime),
Message: types.StringValue(createdJob.Message),
})...)
return
}