Author SHA1 Message Date
cairnr01probe 654bcb0589 chore: update ignore rules 2026-10-27 14:19:58 +03:00
cairnr01probe 8ce81d3ab1 chore: update ignore rules 2026-10-27 14:09:07 +03:00
Naeel 56ec1d548f 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 1960ac33cb 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 ec61c2764f 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 84a54671ee feat: add funcs endpoint — list all functions with triggers for UI 2026-03-18 10:05:15 +03:00
Naeel 87d2422e8b feat: POSTGRES example — add pg-table-reader HTTP function 2026-03-18 09:23:13 +03:00
Naeel 44283a6d26 docs: 2026-03-18 DNS incident — sless-api→sless.kube5s.ru, POSTGRES E2E PASS 2026-03-18 09:07:47 +03:00
Naeel cd5695d0cc 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 9bfed0dc77 fix: remove all prod endpoint references from examples (test stand only) 2026-03-18 08:08:20 +03:00
Naeel 00e4be5e28 docs: E2E results 2026-03-17 — all 4 examples PASS on test stand 2026-03-18 06:27:57 +03:00
cairnr01probe 5ed8d76376 0 2026-03-15 09:38:27 +04:00
Naeel e222dacbe4 feat: demo managed functions with harbor-backed operator setup 2026-03-14 18:16:53 +03:00
Naeel 2516ed61a2 chore: ignore *.tfvars in examples, remove ai_hint_level from pg-list-python 2026-03-12 09:21:20 +03:00
Naeel 29be4b7b5e chore: provider v0.1.18, update examples (harbor branch) 2026-03-12 09:19:17 +03:00
cairnr01probe 1889b18274 chore: terraform.tfvars.change — инструкция по переименованию 2026-03-11 18:38:51 +04:00
cairnr01probe 23cca9bda3 chore: добавлены terraform.tfvars.change — шаблоны для токена (переименовать в terraform.tfvars) 2026-03-11 18:38:07 +04:00
cairnr01probe 9201f5a72c fix: ModifyPlan — terraform plan теперь видит изменения в source_dir (provider v0.1.16) 2026-03-11 18:27:46 +04:00
cairnr01probe 05c9bfb1eb security: убраны реальные токены из terraform.tfvars + инструкция в README 2026-03-11 17:41:34 +04:00
cairnr01probe b74ff361e4 docs: README для примера pg-list-python 2026-03-11 17:24:34 +04:00
cairnr01probe 2726ca8a1e docs: progress.md — harbor integration + UX fixes (v0.1.24–v0.1.28) 2026-03-11 17:23:32 +04:00
cairnr01probe ff1c54340b 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 1b26f5da30 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 5e97f089b4 fix: восстановлен requirements.txt после теста 2026-03-11 16:54:53 +04:00
cairnr01probe 7759a81501 test: невалидный requirements.txt 2026-03-11 16:53:01 +04:00
cairnr01probe a78abfc542 docs: переработан examples/README.md + комментарии function.tf 2026-03-11 16:48:01 +04:00
cairnr01probe d466e9ce98 fix: Decimal→float для JSON сериализации price 2026-03-11 16:39:56 +04:00
cairnr01probe f773fd4a5f feat: пример pg-list-python — список из PostgreSQL без джоба 2026-03-11 16:37:22 +04:00
cairnr01probe da6fb42e69 chore: удалён отладочный push-sample (Harbor интеграция закончена) 2026-03-11 16:33:26 +04:00
cairnr01probe 3c452d17f7 fix: go1.23 build context — COPY . /app/handler/ вместо COPY handler/ 2026-03-11 16:14:42 +04:00
cairnr01probe b8da6b07ab refactor: rename handler.go→greeting.go, buildGreeting() in hello-go example 2026-03-11 16:02:49 +04:00
cairnr01probe a576c19499 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 d3e632eb90 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 64cbd8cb5c Ignore nested examples .git 2026-03-11 14:45:35 +04:00
cairnr01probe c5111b9d2f chore: staged files from prev session (gitignore, examples, harbor guide) 2026-03-11 14:36:32 +04:00
cairnr01probe 9e1a06d1ca docs: удалённая машина теперь полная — kubectl + kubeconfig скопирован 2026-03-11 12:58:39 +04:00
cairnr01probe ee6dba67a3 docs: stress test 4/4 PASS (v0.1.23), баги задокументированы 2026-03-11 12:41:04 +04:00
cairnr01probe dff8febdb2 fix: operator v0.1.23 — BackoffLimit=2 в kaniko, пересоздание Failed функций; script: mod2 96→128 2026-03-11 12:26:06 +04:00
cairnr01probe d510018b2c fix: run_stress_test.sh — tf_output_json для объектных outputs, fix pipeline в assert_step 2026-03-11 11:35:27 +04:00
cairnr01probe 77ee97922a chore: gitignore logs dirs 2026-03-11 11:14:28 +04:00
cairnr01probe 25c13d64e6 test: run_stress_test.sh — полный стресс-тест всех примеров 2026-03-11 11:14:06 +04:00
cairnr01probe 32615c59d5 test: E2E скрипт run_e2e_tests.sh + fix provider token/version в примерах 2026-03-11 10:49:36 +04:00
cairnr01probe 4a4c2f4d2d docs: update for v0.1.22 — Builder SoC, immediate fixes, unit tests 2026-03-11 09:42:12 +04:00
cairnr01probe 5f3e5a6f65 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 7b8617fbe2 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 6e5233d0ad 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 d757caf294 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 615048a6de 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 1ba04efa75 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 a5794d83e0 docs: clarify separate providers decision; fix invocations 501 stub 2026-03-11 07:24:34 +04:00
cairnr01probe 008b93d07a 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 50b26164f5 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 2c7fe99816 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 2e22072934 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 4142cdba43 docs: clarify sless_function description 2026-03-09 20:24:59 +04:00
cairnr01probe bb54d2a681 docs: remove informal tone from README 2026-03-09 20:24:23 +04:00
cairnr01probe 4d3f42b6af docs: add project description and resource types to examples README 2026-03-09 20:23:31 +04:00
cairnr01probe fec2a427e7 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 bd346e5877 fix: restore token in provider blocks (dev-token-change-me is the real token) 2026-03-09 19:51:27 +04:00
cairnr01probe fcd5020577 chore: remove hardcoded token from examples, use SLESS_API_TOKEN env 2026-03-09 19:25:52 +04:00
cairnr01probe 1e592a1a52 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 55e71b5cb2 chore: удалён пример pg-query 2026-03-09 17:46:52 +04:00
cairnr01probe 1a37c94cb4 feat: provider v0.1.10 — source_dir (zip внутри провайдера), убран archive провайдер из всех примеров 2026-03-09 17:45:45 +04:00
cairnr01probe 6c93f6b5b5 docs: examples/README.md — порядок simple-first, без токена, без pg-query 2026-03-09 15:57:29 +04:00
cairnr01probe 7f651b235d 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 e36dbd410a feat: provider v0.1.9 — namespace removed from resources, moved to provider block 2026-03-09 15:12:07 +04:00
cairnr01probe 0235ba6a6d 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 efaa8ce4bf feat: outputs — статус init-джобов для пользователя без kubectl 2026-03-09 14:09:55 +04:00
cairnr01probe d5ffb16259 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 c8cd5d37fe 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 c672696ce6 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 8ac444dd55 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 d46d651e61 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 3df24e57fe refactor: pg-query — ресурсы вынесены в pg-query.tf, main.tf только провайдеры 2026-03-09 09:11:31 +04:00
cairnr01probe 6fbcab8e8a refactor: pg-query — code/ исходники, dist/ zip, archive_file datasource 2026-03-09 09:07:57 +04:00
cairnr01probe cb4f9beb40 refactor: hello-node — zip-файлы в dist/, исходники остаются в code/ 2026-03-09 09:03:00 +04:00
cairnr01probe afe2a053d0 fix: pg-query — filesha256(handler.py) вместо filemd5(handler.zip), версия провайдера 0.1.7 2026-03-09 08:56:19 +04:00
cairnr01probe d299ff655b 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 fadaf96b5e 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 cb582b3165 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 ccdc4f0196 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 86a077802e docs: обновление doc/ по итогам сессии 2026-03-08 2026-03-08 20:36:16 +04:00
cairnr01probe 268191e139 fix: убрать UseStateForUnknown с image_ref, provider v0.1.5 2026-03-08 20:34:00 +04:00
cairnr01probe b08f98eefb config: REGISTRY_HOST переключён с Harbor на DockerHub (naeel) 2026-03-08 20:18:59 +04:00
cairnr01probe 83340e359d fix: FunctionJob зависал в Running — добавлен RequeueAfter polling 2026-03-08 12:13:15 +04:00
cairnr01probe a0fc05abd6 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 56f05b9c7a 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 6f290b3256 ops: switch registry from DockerHub to Harbor (pearlharbor.registryk8s.services.ngcloud.ru/sless) 2026-03-08 09:28:18 +04:00
cairnr01probe 085db75b8a examples/hello-node: two different functions (http greeting, job sum) 2026-03-08 09:22:38 +04:00
cairnr01probe ceaf2d6544 examples/hello-node: move handler to code/, split http.tf and job.tf 2026-03-08 09:14:34 +04:00
cairnr01probe 1ca498bf41 examples: switch hello-node to sless_job (one-shot run) 2026-03-07 23:05:09 +04:00
cairnr01probe a685d34975 examples: auto-zip via hashicorp/archive, remove manual zip step 2026-03-07 18:46:23 +04:00
cairnr01probe 592d12d5e7 doc: обновлена документация — FunctionJob, proxy /fn/, operator v0.1.5, provider v0.1.3 2026-03-07 18:40:36 +04:00
cairnr01probe 176b47ff1c 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 3d2c1ab961 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 d92845caea 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 c850933c13 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 c8c7dc6439 doc: document operator deploy, nodejs20 runtime, Dockerfile fixes, go.mod fix 2026-03-07 17:02:28 +04:00
cairnr01probe ed8b604d76 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 b5924f2824 chore: switch pg-query example to remote endpoint sless-api.kube5s.ru 2026-03-07 16:50:05 +04:00
cairnr01probe a6c09aa601 chore: go mod tidy — fix direct/indirect annotations 2026-03-07 16:31:54 +04:00
cairnr01probe c67dbcba7e 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 1a5fd1825e 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 160641737c 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 18fd1d336b 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 d8698c7e7a 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 cc72454668 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 9336deb49b 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 8dffdc8e0f 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 6baea6d218 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 7243fece9f docs: обновить progress.md по фактическому состоянию
- отражён реальный статус всех компонентов кода
- отражено состояние кластера (CRD, RBAC, postgres)
- добавлен список что ещё не сделано
- добавлены deployments/k8s/rbac.yaml, postgres.yaml
2026-03-07 08:57:54 +04:00
cairnr01probe 028d58cc88 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 3d6c80f4a6 chore: ignore .s3cfg* files 2026-03-07 08:28:33 +04:00
cairnr01probe 21c42d29dd 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 e3cf383daa 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
28 changed files with 331 additions and 2589 deletions
+1
View File
@@ -70,3 +70,4 @@ examples/.git
/s3cfg_registry /s3cfg_registry
/sqs-operator/quick_test.sh /sqs-operator/quick_test.sh
/sqs-operator/sqs_load_test.sh /sqs-operator/sqs_load_test.sh
/.s3cfg_registry
+7 -38
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-03-17 20:00 (bugfix: job-name label удалён в k8s 1.27+, split-brain cached client) // Изменено: 2026-03-09 (feature B: захват stdout пода Job в status.Message)
// FunctionJobReconciler — контроллер одноразовых запусков функций. // FunctionJobReconciler — контроллер одноразовых запусков функций.
// При создании FunctionJob: // При создании FunctionJob:
// 1. Ждёт пока Function станет Ready // 1. Ждёт пока Function станет Ready
@@ -134,13 +134,6 @@ func (r *FunctionJobReconciler) Reconcile(ctx context.Context, req ctrl.Request)
// Автоудаление через 10 мин после завершения — чтобы не засорять кластер // Автоудаление через 10 мин после завершения — чтобы не засорять кластер
TTLSecondsAfterFinished: &ttl, TTLSecondsAfterFinished: &ttl,
Template: corev1.PodTemplateSpec{ Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
"managed-by": "sless",
"functionjob": fj.Name,
"function": fn.Name,
},
},
Spec: corev1.PodSpec{ Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyNever, RestartPolicy: corev1.RestartPolicyNever,
// Используем тот же образ что и Deployment функции // Используем тот же образ что и Deployment функции
@@ -201,19 +194,12 @@ func (r *FunctionJobReconciler) syncJobStatus(ctx context.Context, fj *slessv1al
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseSucceeded fj.Status.Phase = slessv1alpha1.FunctionJobPhaseSucceeded
fj.Status.CompletionTime = &now fj.Status.CompletionTime = &now
// Захватываем stdout пода — это return value функции (runner делает print(json.dumps(result))) // Захватываем stdout пода — это return value функции (runner делает print(json.dumps(result)))
fj.Status.Message = getJobPodOutput(ctx, r.KubeClient, job.Namespace, "functionjob="+fj.Name) fj.Status.Message = getJobPodOutput(ctx, r.KubeClient, job.Namespace, job.Name)
} else if job.Status.Failed > 0 { } else if job.Status.Failed > 0 {
now := metav1.Now() now := metav1.Now()
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseFailed fj.Status.Phase = slessv1alpha1.FunctionJobPhaseFailed
fj.Status.CompletionTime = &now fj.Status.CompletionTime = &now
// Захватываем логи по нашему лейблу functionjob= (работает во всех версиях k8s). fj.Status.Message = "job failed, check pod logs: kubectl logs -n sless-fn-" + fj.Namespace + " -l functionjob=" + fj.Name
// Устаревший 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 { } else {
// Job ещё выполняется — перечитаем через 5 секунд // Job ещё выполняется — перечитаем через 5 секунд
if err := r.Status().Update(ctx, fj); err != nil { if err := r.Status().Update(ctx, fj); err != nil {
@@ -227,17 +213,6 @@ func (r *FunctionJobReconciler) syncJobStatus(ctx context.Context, fj *slessv1al
return ctrl.Result{}, nil 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-сервера. // runtimeRunnerCommand возвращает CMD для запуска одноразового runner вместо HTTP-сервера.
// runner читает env SLESS_EVENT и SLESS_ENTRYPOINT, вызывает handle(event) один раз и завершается. // runner читает env SLESS_EVENT и SLESS_ENTRYPOINT, вызывает handle(event) один раз и завершается.
func runtimeRunnerCommand(runtime string) []string { func runtimeRunnerCommand(runtime string) []string {
@@ -305,22 +280,16 @@ func goJobModeEnv(runtime string) []corev1.EnvVar {
return nil return nil
} }
// getJobPodOutput находит под по labelSelector и возвращает его stdout+stderr (trimmed). // getJobPodOutput находит под созданный Job-ом и возвращает его stdout (trimmed).
// runner.py/runner.js печатают json.dumps(result) в stdout — return value функции. // runner.py/runner.js печатают json.dumps(result) в stdout — это и есть return value функции.
// Исключения/трейсбэки Python/Node пишут в stderr — поэтому собираем оба потока.
// Если под не найден или логи недоступны — возвращает "completed successfully" как fallback. // Если под не найден или логи недоступны — возвращает "completed successfully" как fallback.
// labelSelector передаётся снаружи — вызывающий код использует "functionjob=<name>" (наш лейбл, func getJobPodOutput(ctx context.Context, kube kubernetes.Interface, namespace, jobName string) string {
// выставляется на 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{ pods, err := kube.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
LabelSelector: labelSelector, LabelSelector: "job-name=" + jobName,
}) })
if err != nil || len(pods.Items) == 0 { if err != nil || len(pods.Items) == 0 {
return "completed successfully" return "completed successfully"
} }
// Stdout: true, Stderr: true — собираем оба потока.
// Python исключения идут в stderr, runner.py пишет результат в stdout.
req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{}) req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{})
stream, err := req.Stream(ctx) stream, err := req.Stream(ctx)
if err != nil { if err != nil {
+2 -2
View File
@@ -3,7 +3,7 @@
# Состав: # Состав:
# - ConfigMap: не-секретные env vars (S3_ENDPOINT, REGISTRY_HOST и т.д.) # - ConfigMap: не-секретные env vars (S3_ENDPOINT, REGISTRY_HOST и т.д.)
# - Secret: секретные данные (S3 keys, postgres DSN, API token, Harbor pass) # - Secret: секретные данные (S3 keys, postgres DSN, API token, Harbor pass)
# - Deployment: оператор naeel/sless-operator:v0.1.33 в namespace sless # - Deployment: оператор naeel/sless-operator:v0.1.23 в namespace sless
# - Service: ClusterIP :9090 (REST API) # - Service: ClusterIP :9090 (REST API)
# - Ingress: sless.kube5s.ru → :9090 (внешний доступ с TLS) # - Ingress: sless.kube5s.ru → :9090 (внешний доступ с TLS)
# #
@@ -72,7 +72,7 @@ spec:
containers: containers:
- name: operator - name: operator
# При обновлении версии оператора — менять тег здесь (не latest!) # При обновлении версии оператора — менять тег здесь (не latest!)
image: naeel/sless-operator:v0.1.33 image: naeel/sless-operator:v0.1.32
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый) # Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
+8 -38
View File
@@ -1,7 +1,5 @@
# API Design # API Design
Последнее обновление: 2026-03-18
## Базовый URL ## Базовый URL
``` ```
@@ -9,38 +7,15 @@ http://<operator-host>:9090/v1
``` ```
Локально: `http://localhost: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 формат):
``` ```
GET /v1/namespaces/{ns}/functions /v1/namespaces/{namespace}/functions[/{name}]
POST /v1/namespaces/{ns}/functions /v1/namespaces/{namespace}/functions/{name}/upload
GET /v1/namespaces/{ns}/functions/{name} /v1/namespaces/{namespace}/triggers[/{name}]
PUT /v1/namespaces/{ns}/functions/{name} /v1/namespaces/{namespace}/functions/{name}/invocations
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
``` ```
## Аутентификация ## Аутентификация
@@ -49,11 +24,6 @@ GET https://sless.kube5s.ru/health ← liveness
Authorization: Bearer <cloud-token> Authorization: Bearer <cloud-token>
``` ```
Токен — JWT от `auth-api`. Middleware в операторе:
1. Извлекает `sub` из payload (без проверки подписи — доверяет Ingress)
2. Вычисляет namespace: `SHA256(sub)[:8]` hex → `sless-{16 hex символов}`
3. Проверяет что запрошенный `{namespace}` совпадает с вычисленным
## Ресурсы ## Ресурсы
### Functions ### Functions
@@ -117,8 +87,8 @@ field: code = <zip-file>
## Поддерживаемые runtime (v1) ## Поддерживаемые runtime (v1)
- `python3.11` — реализован и протестирован - `python3.11` — реализован и протестирован
- `nodejs20` — реализован и протестирован - `go1.21` — планируется
- `go1.23` — реализован - `nodejs20` — планируется
## Модель Function ## Модель Function
@@ -1,368 +0,0 @@
# 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 |
+1 -40
View File
@@ -1,6 +1,6 @@
# Архитектура системы # Архитектура системы
Последнее обновление: 2026-03-18 (v0.1.34 + funcs-service v0.2.0) Последнее обновление: 2026-03-11 (v0.1.22)
## Общее описание ## Общее описание
@@ -13,7 +13,6 @@ Managed Serverless Functions Service для облачного провайде
| Компонент | Технология | Где запущен | | Компонент | Технология | Где запущен |
|-----------|-----------|-------------| |-----------|-----------|-------------|
| Operator (API + Controllers) | Go (controller-runtime) | Kubernetes, namespace `sless` | | Operator (API + Controllers) | Go (controller-runtime) | Kubernetes, namespace `sless` |
| funcs-service (глобальная консоль) | Go (net/http) | Kubernetes, namespace `sless` |
| PostgreSQL | PostgreSQL 16 | Kubernetes, namespace `sless` | | PostgreSQL | PostgreSQL 16 | Kubernetes, namespace `sless` |
| S3 | Ceph (облачный) | `s3.msk-1.ngcloud.ru` | | S3 | Ceph (облачный) | `s3.msk-1.ngcloud.ru` |
| Container Registry | DockerHub (`naeel/`) | внешний | | Container Registry | DockerHub (`naeel/`) | внешний |
@@ -26,44 +25,6 @@ Managed Serverless Functions Service для облачного провайде
> Redis и RabbitMQ — отложены до v2. > 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 ## Изоляция пользователей — Namespace per user
Каждый пользователь облака получает **отдельный k8s namespace**. Каждый пользователь облака получает **отдельный k8s namespace**.
+33 -90
View File
@@ -1,106 +1,49 @@
# Структура проекта # Структура проекта
Последнее обновление: 2026-03-18 (v0.1.34)
## Репозиторий ## Репозиторий
`gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless` `gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless`
## Директории (актуальное состояние) ## Директории
``` ```
sless/ sless/
├── main.go # точка входа оператора (controller-runtime + HTTP сервер) ├── cmd/
├── Dockerfile # multi-stage сборка оператора │ └── api/
├── go.mod # module: gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless │ └── main.go # точка входа API сервера
├── 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/ ├── internal/
│ ├── api/ │ ├── api/
│ │ ├── router.go # gorilla/mux: регистрация всех маршрутов │ │ ├── handler/ # HTTP хендлеры (functions, versions, triggers)
│ │ ├── middleware/ │ │ ├── middleware/ # auth, logging, rate limit
│ │ │ ├── auth.go # JWT Bearer → namespace (SHA256) │ │ └── router.go # регистрация маршрутов
│ │ │ └── logging.go # access log │ ├── model/ # доменные модели: Function, Version, Trigger, Invocation
│ │ └── 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/ │ ├── storage/
│ │ ├── s3/client.go # minio-go: Upload, UploadContext, Download, Delete │ │ ├── postgres/ # CRUD функций, версий, логов вызовов
│ │ └── postgres/ # хранение invocation logs │ │ └── s3/ # загрузка/скачивание zip архивов кода
│ └── config/ # конфиг из env переменных │ ├── builder/ # сборка Docker образа из кода пользователя
├── services/ │ ├── runner/ # запуск функций в k8s (Jobs / Deployments)
│ └── funcs/ # отдельный HTTP сервис (web-консоль) │ └── config/ # конфиг из env переменных
│ ├── main.go # v0.2.0: HTML+plain text+proxy к оператору ├── migrations/ # SQL миграции (numbered: 001_, 002_, ...)
│ ├── index.html # dark-themed HTML консоль (go:embed) ├── deployments/
│ ├── Dockerfile # multi-stage Go → alpine (копирует main.go + index.html) │ └── k8s/ # манифесты: Deployment, Service, Ingress, RBAC
│ └── go.mod # отдельный Go модуль ├── api/
├── migrations/ │ └── openapi.yaml # OpenAPI 3.0 спецификация
│ └── 001_initial.sql # схема PostgreSQL (invocation logs) ├── doc/ # документация проекта (эта папка)
├── deployments/k8s/ └── docker-compose.yml # локальная разработка: postgres, redis, minio
│ ├── 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 модули ## Go module
Два независимых Go модуля в одном репозитории: ```
module gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless
```
| Модуль | Путь | Назначение | ## Порядок разработки
|--------|------|-----------|
| `gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless` | `/` | Оператор + API | 1. `internal/config` + `internal/model` — базовые структуры данных
| `gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/services/funcs` | `services/funcs/` | Web-консоль сервис | 2. `migrations/` + `internal/storage/postgres` — схема БД и CRUD
| `terra.k8c.ru/naeel/sless` | `terraform/provider/` | Terraform провайдер | 3. `internal/api` — HTTP хендлеры, роутер, middleware
4. `internal/storage/s3` — загрузка кода функций
5. `internal/builder` — сборка Docker образов
6. `internal/runner` — запуск функций в k8s
7. `deployments/k8s` — манифесты для деплоя
-159
View File
@@ -1,159 +0,0 @@
# 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,190 +2,6 @@
> Сюда записываем проблемы с которыми столкнулись и как их решили. > Сюда записываем проблемы с которыми столкнулись и как их решили.
---
## 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
@@ -1,204 +0,0 @@
# Архитектура системы
Последнее обновление: 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 |
+1 -134
View File
@@ -1,139 +1,6 @@
# Прогресс разработки # Прогресс разработки
Последнее обновление: 2026-03-19 09:00 Последнее обновление: 2026-03-18 12:00 (DNS инцидент устранён, POSTGRES E2E PASS)
## 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 → мёртвый кластер ## 2026-03-18 — DNS инцидент: sless-api.kube5s.ru → мёртвый кластер
+95 -60
View File
@@ -1,31 +1,35 @@
# 2026-03-18 (обновлено: plain text вывод; фильтрация SLESS_EXCLUDE) # 2026-03-18 (обновлено: фильтрация SLESS_EXCLUDE, читаемый вывод через "#"-ключ)
# funcs_list.py — HTTP-функция: список пользовательских функций, человекочитаемый plain text. # funcs_list.py — HTTP-функция: список всех пользовательских функций с их статусами.
# Вызывает внутренний REST API оператора (ClusterIP, без TLS). # Вызывает внутренний REST API оператора (ClusterIP, без TLS).
# Возвращает str → python runtime отдаёт text/plain напрямую без json.dumps. # Объединяет данные функций и триггеров в один ответ; скрывает служебные функции.
# #
# Env vars: # Env vars:
# SLESS_API_URL — URL оператора (http://sless-operator.sless.svc.cluster.local:9090) # SLESS_API_URL — URL оператора (http://sless-operator.sless.svc.cluster.local:9090)
# SLESS_NAMESPACE — namespace пользователя (sless-{hex16}) # SLESS_NAMESPACE — namespace пользователя (sless-{hex16})
# SLESS_TOKEN — JWT токен для /v1/ API # SLESS_TOKEN — JWT токен для /v1/ API
# SLESS_EXTERNAL_URL — публичный базовый URL (https://sless.kube5s.ru) # SLESS_EXTERNAL_URL — публичный базовый URL (https://sless.kube5s.ru), для корректных ссылок
# SLESS_EXCLUDE — comma-separated имена функций, которые не показывать # SLESS_EXCLUDE — comma-separated имена функций, которые не надо показывать
# Пример: "funcs,event-writer,event-monitor,event-cleaner"
#
# Формат вывода: JSON-объект, где каждая функция содержит поле "#" — краткий комментарий.
# При pretty-print (python3 -m json.tool) выглядит как читаемый список с аннотациями.
import os import os
import requests import requests
SEP = "─" * 52
def _short_comment(fn, http_triggers, cron_triggers):
"""Генерирует однострочный комментарий-описание функции по её метаданным."""
phase = fn.get("phase", "")
runtime = fn.get("runtime", "")
def _comment(fn, http_trigs, cron_trigs): if http_triggers:
phase = fn.get("phase", "?") active_str = "активна" if http_triggers[0].get("active") else "неактивна"
runtime = fn.get("runtime", "?") return f"HTTP endpoint ({runtime}) — {phase}, {active_str}"
if http_trigs: elif cron_triggers:
active = "активна" if http_trigs[0].get("active") else "неактивна" schedule = cron_triggers[0].get("schedule", "?")
return f"HTTP endpoint ({runtime}) — {phase}, {active}" active_str = "активна" if cron_triggers[0].get("active") else "неактивна"
elif cron_trigs: return f"Cron '{schedule}' ({runtime}) — {phase}, {active_str}"
schedule = cron_trigs[0].get("schedule", "?")
active = "активна" if cron_trigs[0].get("active") else "неактивна"
return f"Cron '{schedule}' ({runtime}) — {phase}, {active}"
else: else:
return f"Job/runner без триггера ({runtime}) — {phase}" return f"Job/runner без триггера ({runtime}) — {phase}"
@@ -35,60 +39,91 @@ def list_all(event):
namespace = os.environ["SLESS_NAMESPACE"] namespace = os.environ["SLESS_NAMESPACE"]
token = os.environ["SLESS_TOKEN"] token = os.environ["SLESS_TOKEN"]
ext_url = os.environ.get("SLESS_EXTERNAL_URL", "").rstrip("/") ext_url = os.environ.get("SLESS_EXTERNAL_URL", "").rstrip("/")
exclude = {n.strip() for n in os.environ.get("SLESS_EXCLUDE", "").split(",") if n.strip()}
# Имена функций, которые не должны присутствовать в выводе.
# Включает саму себя ("funcs") и служебные функции других примеров.
exclude = {
n.strip()
for n in os.environ.get("SLESS_EXCLUDE", "").split(",")
if n.strip()
}
headers = {"Authorization": f"Bearer {token}"} 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 = {} fns_resp = requests.get(
for tr in trs.json(): 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") fn_name = tr.get("function") or tr.get("functionRef")
if fn_name: if fn_name:
trig_idx.setdefault(fn_name, []).append(tr) triggers_by_fn.setdefault(fn_name, []).append(tr)
items = [] result = []
for fn in fns.json(): for fn in fns_resp.json():
name = fn["name"] name = fn["name"]
if name in exclude: if name in exclude:
continue 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"] http_triggers = [
is_active = any(t.get("enabled", True) and t.get("active", False) for t in trig_idx.get(name, [])) t for t in triggers_by_fn.get(name, []) if t.get("type") == "http"
items.append((fn, http_t, cron_t, is_active)) ]
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)
# Сортировка: активные вверх, затем по имени # Сортировка: активные вверх, затем по имени
items.sort(key=lambda x: (not x[3], x[0]["name"])) result.sort(key=lambda f: (not f["active"], f["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"
return {
"namespace": namespace,
"count": len(result),
"functions": result,
}
@@ -0,0 +1,27 @@
# 2026-03-18
# table_reader.py — HTTP-функция: читает строки из terraform_demo_table и возвращает JSON.
# Подключается к Postgres через env vars (те же что у sql-runner).
import os
import psycopg2
import psycopg2.extras
def list_rows(event):
# Возвращает все строки terraform_demo_table в порядке убывания created_at.
connection = 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"),
)
try:
cursor = connection.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cursor.execute(
"SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC"
)
rows = [dict(row) for row in cursor.fetchall()]
return {"rows": rows, "count": len(rows)}
finally:
connection.close()
-130
View File
@@ -1,130 +0,0 @@
# 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()
+5 -37
View File
@@ -126,14 +126,13 @@ resource "sless_trigger" "pg_info_http" {
enabled = true enabled = true
} }
# HTTP-функции чтения и записи строк terraform_demo_table — в одном файле table_rw.py. # HTTP-функция читает строки из terraform_demo_table и возвращает JSON.
# list_rows (GET) — читает все строки; add_row (POST {title}) — вставляет строку. # Использует те же credentials что и sql-runner.
# Доступны по URL: https://sless.kube5s.ru/fn/<namespace>/pg-table-reader # Доступна по 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" { resource "sless_function" "postgres_table_reader" {
name = "pg-table-reader" name = "pg-table-reader"
runtime = "python3.11" runtime = "python3.11"
entrypoint = "table_rw.list_rows" entrypoint = "table_reader.list_rows"
memory_mb = 128 memory_mb = 128
timeout_sec = 30 timeout_sec = 30
@@ -146,7 +145,7 @@ resource "sless_function" "postgres_table_reader" {
PGSSLMODE = "require" PGSSLMODE = "require"
} }
source_dir = "${path.module}/code/table-rw" source_dir = "${path.module}/code/table-reader"
depends_on = [sless_job.postgres_table_init_job] depends_on = [sless_job.postgres_table_init_job]
} }
@@ -162,35 +161,4 @@ output "table_reader_url" {
value = sless_trigger.postgres_table_reader_http.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
}
+1 -4
View File
@@ -66,13 +66,10 @@ func (h *Handler) InvokeFunction(w http.ResponseWriter, r *http.Request) {
return return
} }
// Пробрасываем Content-Type и Content-Length если есть. // Пробрасываем Content-Type если есть
// Content-Length обязателен: Python BaseHTTPRequestHandler читает тело
// ровно столько байт, сколько указано в заголовке; без него body = пусто.
if ct := r.Header.Get("Content-Type"); ct != "" { if ct := r.Header.Get("Content-Type"); ct != "" {
proxyReq.Header.Set("Content-Type", ct) proxyReq.Header.Set("Content-Type", ct)
} }
proxyReq.ContentLength = r.ContentLength
resp, err := httpClient.Do(proxyReq) resp, err := httpClient.Do(proxyReq)
if err != nil { if err != nil {
-119
View File
@@ -1,119 +0,0 @@
// Создано: 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
}
+1 -4
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-04-25 // Изменено: 2026-03-08
// router.go — регистрация всех REST-маршрутов через gorilla/mux. // router.go — регистрация всех REST-маршрутов через gorilla/mux.
// Все маршруты защищены Bearer-токеном (middleware.Auth). // Все маршруты защищены Bearer-токеном (middleware.Auth).
// Маршруты сгруппированы по /v1/namespaces/{namespace}/... // Маршруты сгруппированы по /v1/namespaces/{namespace}/...
@@ -45,9 +45,6 @@ func NewRouter(h *handler.Handler, log *slog.Logger) http.Handler {
// Upload code — принимает zip, генерирует Dockerfile, кладёт tar.gz в S3, запускает сборку // Upload code — принимает zip, генерирует Dockerfile, кладёт tar.gz в S3, запускает сборку
v1.HandleFunc("/namespaces/{namespace}/functions/{name}/upload", h.UploadCode).Methods(http.MethodPost) 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 // Triggers CRUD
v1.HandleFunc("/namespaces/{namespace}/triggers", h.ListTriggers).Methods(http.MethodGet) v1.HandleFunc("/namespaces/{namespace}/triggers", h.ListTriggers).Methods(http.MethodGet)
v1.HandleFunc("/namespaces/{namespace}/triggers", h.CreateTrigger).Methods(http.MethodPost) v1.HandleFunc("/namespaces/{namespace}/triggers", h.CreateTrigger).Methods(http.MethodPost)
+1 -1
View File
@@ -125,7 +125,7 @@ func (b *Builder) Build(ctx context.Context, namespace, funcName, s3Key string)
Args: []string{ Args: []string{
"--context=" + s3ContextURL, "--context=" + s3ContextURL,
"--destination=" + imageRef, "--destination=" + imageRef,
"--no-cache", // без кэша — гарантирует что COPY берёт свежий код из S3 "--cache=true", // кешируем слои для ускорения повторных сборок
}, },
Env: []corev1.EnvVar{ Env: []corev1.EnvVar{
{Name: "AWS_ACCESS_KEY_ID", Value: b.s3AccessKey}, {Name: "AWS_ACCESS_KEY_ID", Value: b.s3AccessKey},
+1 -1
View File
@@ -66,7 +66,7 @@ func PrepareContext(zipData []byte, runtime string) (*bytes.Buffer, error) {
func runtimeBaseImage(runtime string) (string, error) { func runtimeBaseImage(runtime string) (string, error) {
switch runtime { switch runtime {
case "python3.11": case "python3.11":
return "naeel/sless-runtime-python3.11:v0.1.4", nil return "naeel/sless-runtime-python3.11:v0.1.2", nil
case "nodejs20": case "nodejs20":
return "naeel/sless-runtime-nodejs20:v0.1.2", nil return "naeel/sless-runtime-nodejs20:v0.1.2", nil
case "go1.23": case "go1.23":
+3 -14
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# Изменено: 2026-03-18 (string return → text/plain без json.dumps) # Изменено: 2026-03-11
# HTTP-обёртка для serverless функций на Python 3.11. # HTTP-обёртка для serverless функций на Python 3.11.
# Загружает модуль из SLESS_ENTRYPOINT или handler.py по умолчанию. # Загружает модуль из SLESS_ENTRYPOINT или handler.py по умолчанию.
# Формат SLESS_ENTRYPOINT: "module_name.func_name" (например: handler.handle) # Формат SLESS_ENTRYPOINT: "module_name.func_name" (например: handler.handle)
@@ -56,8 +56,6 @@ class FunctionHandler(BaseHTTPRequestHandler):
event['_path'] = parsed.path event['_path'] = parsed.path
event['_query'] = query event['_query'] = query
event['_method'] = self.command event['_method'] = self.command
# Accept заголовок — позволяет функции различать браузер и curl/API.
event['_accept'] = self.headers.get('Accept', '')
return event return event
def do_GET(self): def do_GET(self):
@@ -112,18 +110,9 @@ class FunctionHandler(BaseHTTPRequestHandler):
self.wfile.write(body) self.wfile.write(body)
def _respond(self, status, data): 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_response(status)
self.send_header("Content-Type", ctype) self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
self.end_headers() self.end_headers()
self.wfile.write(body) self.wfile.write(body)
+3 -2
View File
@@ -1,10 +1,11 @@
# 2026-04-25 (добавлен index.html — встраивается через go:embed) # 2026-03-18
# Dockerfile для sless-funcs-service. # Dockerfile для sless-funcs-service.
# Многоэтапная сборка: Go → alpine (минимальный образ). # Многоэтапная сборка: Go → alpine (минимальный образ).
# Сервис не нуждается во внешних зависимостях — только stdlib.
FROM golang:1.23-alpine AS builder FROM golang:1.23-alpine AS builder
WORKDIR /build WORKDIR /build
COPY go.mod main.go index.html ./ COPY go.mod main.go ./
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o funcs-service . RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o funcs-service .
FROM alpine:3.20 FROM alpine:3.20
-107
View File
@@ -1,107 +0,0 @@
# 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
-492
View File
@@ -1,492 +0,0 @@
<!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>
+132 -331
View File
@@ -1,29 +1,25 @@
// Изменено: 2026-04-25 (добавлена HTML web-консоль, proxy endpoints для source/triggers) // 2026-03-18
// main.go — глобальный HTTP сервис листинга функций пользователя. // main.go — глобальный HTTP сервис листинга функций пользователя.
// Развёрнут ОДИН РАЗ в namespace sless; работает для ВСЕХ пользователей. // Развёрнут ОДИН РАЗ в namespace sless; работает для ВСЕХ пользователей.
// Не связан с terraform — деплоится манифестом deployments/k8s/funcs-service.yaml. // Не связан с terraform — деплоится манифестом deployments/k8s/funcs-service.yaml.
// //
// Маршруты: // Логика:
// GET /funcs — usage hint (без токена) // 1. Принимает Authorization: Bearer {jwt} от пользователя
// GET /funcs?token=<jwt> — листинг через JWT токен // 2. Извлекает sub из JWT (без проверки подписи — trusted perimeter за Ingress)
// GET /funcs/<namespace> — листинг (Accept: text/html → HTML, иначе plain text) // 3. Вычисляет namespace = SHA256(sub)[:8] hex (та же логика что в операторе)
// GET /funcs/<namespace>/source/<fn> — прокси к оператору: файлы исходного кода (JSON) // 4. Вызывает внутренний API оператора: GET /v1/namespaces/{ns}/functions + /triggers
// PATCH /funcs/<namespace>/triggers/<n> — прокси к оператору: enable/disable триггера // 5. Возвращает plain text — человекочитаемый список
// GET /health — liveness/readiness probe
// //
// Env vars: // Env vars:
// SLESS_OPERATOR_URL — URL оператора внутри кластера (default: http://sless-operator.sless.svc.cluster.local:9090) // SLESS_OPERATOR_URL — URL оператора внутри кластера (default: http://sless-operator.sless.svc.cluster.local:9090)
// SLESS_EXTERNAL_URL — публичный базовый URL для корректных ссылок на функции // SLESS_EXTERNAL_URL — публичный базовый URL для корректных ссылок на функции
// SLESS_EXCLUDE — comma-separated список имён функций, скрытых из листинга // SLESS_EXCLUDE — comma-separated список имён функций, скрытых из листинга
// SLESS_SERVICE_TOKEN — токен сервиса для запросов к оператору (для /funcs/<namespace> без токена юзера)
// PORT — порт сервера (default: 8090) // PORT — порт сервера (default: 8090)
package main package main
import ( import (
"bytes"
"crypto/sha256" "crypto/sha256"
_ "embed"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -31,14 +27,10 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"regexp"
"sort" "sort"
"strings" "strings"
) )
//go:embed index.html
var htmlPageTemplate string
// fnResponse — ответ /v1/namespaces/{ns}/functions (подмножество полей оператора) // fnResponse — ответ /v1/namespaces/{ns}/functions (подмножество полей оператора)
type fnResponse struct { type fnResponse struct {
Name string `json:"name"` Name string `json:"name"`
@@ -60,22 +52,11 @@ type trResponse struct {
URL string `json:"url"` 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() { func main() {
operatorURL := strings.TrimRight(env("SLESS_OPERATOR_URL", "http://sless-operator.sless.svc.cluster.local:9090"), "/") operatorURL := strings.TrimRight(env("SLESS_OPERATOR_URL", "http://sless-operator.sless.svc.cluster.local:9090"), "/")
externalURL := strings.TrimRight(env("SLESS_EXTERNAL_URL", ""), "/") externalURL := strings.TrimRight(env("SLESS_EXTERNAL_URL", ""), "/")
port := env("PORT", "8090") port := env("PORT", "8090")
// serviceToken — токен сервиса для запросов к оператору по /funcs/<namespace> (без токена пользователя)
serviceToken := os.Getenv("SLESS_SERVICE_TOKEN") serviceToken := os.Getenv("SLESS_SERVICE_TOKEN")
exclude := map[string]bool{} exclude := map[string]bool{}
@@ -85,333 +66,153 @@ func main() {
} }
} }
// /funcs (точное совпадение) — namespace из JWT токена (?token= или Authorization header) http.HandleFunc("/funcs", handler(operatorURL, externalURL, serviceToken, exclude))
http.HandleFunc("/funcs", handleFuncsToken(operatorURL, externalURL, exclude)) // /health — для liveness/readiness probe без Bearer токена
// /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) { http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintln(w, "ok") fmt.Fprintln(w, "ok")
}) })
// /funcs/<namespace> — без токена, namespace виден в URL всех функций
http.HandleFunc("/funcs/", handler(operatorURL, externalURL, serviceToken, exclude))
log.Printf("sless-funcs-service listening on :%s (operator: %s)", port, operatorURL) log.Printf("sless-funcs-service listening on :%s (operator: %s)", port, operatorURL)
log.Fatal(http.ListenAndServe(":"+port, nil)) log.Fatal(http.ListenAndServe(":"+port, nil))
} }
// handleFuncsToken обрабатывает GET /funcs с JWT токеном (?token= или Authorization header). func handler(operatorURL, externalURL, serviceToken string, exclude map[string]bool) http.HandlerFunc {
func handleFuncsToken(operatorURL, externalURL string, exclude map[string]bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token") var namespace, authHeader string
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]. // Вариант 1: /funcs/<namespace> — namespace прямо в URL, токен не нужен.
// // Namespace виден в URL каждой функции — не является секретом.
// Маршруты: if strings.HasPrefix(r.URL.Path, "/funcs/") {
// namespace = strings.TrimPrefix(r.URL.Path, "/funcs/")
// GET /funcs/{ns} — листинг (HTML если Accept: text/html, иначе plain text) namespace = strings.Trim(namespace, "/")
// GET /funcs/{ns}/source/{fn} — прокси: файлы исходного кода функции if namespace == "" {
// PATCH /funcs/{ns}/triggers/{name} — прокси: enable/disable триггер http.Error(w, "usage: /funcs/<namespace>\n", http.StatusBadRequest)
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 return
} }
} // Используем serviceToken сервиса, если задан
if serviceToken != "" {
if len(parts) > 1 { authHeader = "Bearer " + serviceToken
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)
} }
} // authHeader может остаться пустым — оператор ответит 401 если не настроен
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 { } else {
sb.WriteString(" active: нет\n") // Вариант 2: ?token=<jwt> — токен как query param (можно bookmarkнуть URL)
} token := r.URL.Query().Get("token")
if len(it.httpT) > 0 { // Вариант 3: Authorization: Bearer <jwt> — стандартный заголовок
url := it.httpT[0].URL if token == "" {
if externalURL != "" { token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
url = externalURL + "/fn/" + ns + "/" + fn.Name
} }
sb.WriteString(fmt.Sprintf(" url: %s\n", url)) if token == "" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, "Укажите namespace в URL или передайте токен:\n\n")
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
}
namespace = namespaceFromSub(sub)
authHeader = "Bearer " + token
} }
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") fns, err := apiGet[[]fnResponse](operatorURL+"/v1/namespaces/"+namespace+"/functions", authHeader)
fmt.Fprint(w, sb.String()) if err != nil {
} http.Error(w, fmt.Sprintf("operator error (functions): %s\n", err), http.StatusBadGateway)
return
// 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)
} }
} trs, err := apiGet[[]trResponse](operatorURL+"/v1/namespaces/"+namespace+"/triggers", authHeader)
sort.Slice(result, func(i, j int) bool { if err != nil {
ai := isActive(result[i].Name, trigIdx) http.Error(w, fmt.Sprintf("operator error (triggers): %s\n", err), http.StatusBadGateway)
aj := isActive(result[j].Name, trigIdx) return
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 { trigIdx := map[string][]trResponse{}
for _, tr := range trigIdx[funcName] { for _, tr := range trs {
if tr.Enabled && tr.Active { trigIdx[tr.FunctionRef] = append(trigIdx[tr.FunctionRef], tr)
return true
} }
}
return false
}
// isValidK8sName базовая проверка имени k8s-ресурса: нет path traversal символов. // Фильтрация и сортировка: активные вверх, потом по имени
func isValidK8sName(s string) bool { type entry struct {
return s != "" && !strings.Contains(s, "/") && !strings.Contains(s, "..") && len(s) <= 253 fn fnResponse
httpT []trResponse
cronT []trResponse
isActive bool
}
var items []entry
for _, fn := range fns {
if exclude[fn.Name] {
continue
}
var httpT, cronT []trResponse
isActive := false
for _, tr := range trigIdx[fn.Name] {
switch tr.Type {
case "http":
httpT = append(httpT, tr)
case "cron":
cronT = append(cronT, tr)
}
if tr.Enabled && tr.Active {
isActive = true
}
}
items = append(items, entry{fn, httpT, cronT, isActive})
}
sort.Slice(items, func(i, j int) bool {
if items[i].isActive != items[j].isActive {
return items[i].isActive
}
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 it.isActive {
sb.WriteString(" active: да\n")
} else {
sb.WriteString(" active: нет\n")
}
if len(it.httpT) > 0 {
url := it.httpT[0].URL
if externalURL != "" {
url = externalURL + "/fn/" + namespace + "/" + 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", namespace, len(items)))
sb.WriteString(sep + "\n")
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprint(w, sb.String())
}
} }
func buildComment(fn fnResponse, httpT, cronT []trResponse) string { func buildComment(fn fnResponse, httpT, cronT []trResponse) string {
+1 -8
View File
@@ -1,4 +1,4 @@
// 2026-03-17 12:20 // 2026-03-11
// client.go — HTTP-клиент для REST API sless оператора. // client.go — HTTP-клиент для REST API sless оператора.
// Изолирован от terraform-plugin-framework — зависит только от stdlib и net/http. // Изолирован от terraform-plugin-framework — зависит только от stdlib и net/http.
// Все методы принимают ctx для правильной работы с таймаутами terraform. // Все методы принимают ctx для правильной работы с таймаутами terraform.
@@ -22,7 +22,6 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"mime/multipart" "mime/multipart"
@@ -45,9 +44,6 @@ type Client struct {
Namespace string Namespace string
} }
// ErrJobAlreadyExists возвращается при попытке создать FunctionJob с уже существующим именем.
var ErrJobAlreadyExists = errors.New("job already exists")
// New создаёт клиент. // New создаёт клиент.
// - endpoint — базовый URL оператора (без trailing slash), например "https://sless-api.kube5s.ru" // - endpoint — базовый URL оператора (без trailing slash), например "https://sless-api.kube5s.ru"
// - token — Bearer JWT-токен облака // - token — Bearer JWT-токен облака
@@ -460,9 +456,6 @@ func (c *Client) CreateJob(ctx context.Context, ns string, req JobRequest) (*Job
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated { if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body) 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) return nil, fmt.Errorf("create job: status %d: %s", resp.StatusCode, body)
} }
var j JobResponse var j JobResponse
@@ -1,4 +1,4 @@
// 2026-03-17 12:20 // 2026-03-08
// job_resource.go — Terraform ресурс sless_job. // job_resource.go — Terraform ресурс sless_job.
// //
// Lifecycle: // Lifecycle:
@@ -17,7 +17,6 @@ package resources
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"time" "time"
@@ -164,28 +163,15 @@ func (r *JobResource) Create(ctx context.Context, req resource.CreateRequest, re
// run_id=0: создаём FunctionJob в k8s, но оператор не запустит k8s Job. // run_id=0: создаём FunctionJob в k8s, но оператор не запустит k8s Job.
// Пользователь может поменять run_id > 0 позже чтобы запустить. // Пользователь может поменять run_id > 0 позже чтобы запустить.
runID := plan.RunID.ValueInt64() runID := plan.RunID.ValueInt64()
createdJob, err := r.client.CreateJob(ctx, ns, client.JobRequest{ _, err := r.client.CreateJob(ctx, ns, client.JobRequest{
Name: plan.Name.ValueString(), Name: plan.Name.ValueString(),
FunctionRef: plan.Function.ValueString(), FunctionRef: plan.Function.ValueString(),
EventJSON: eventJSON, EventJSON: eventJSON,
RunID: runID, RunID: runID,
}) })
if err != nil { if err != nil {
if errors.Is(err, client.ErrJobAlreadyExists) { resp.Diagnostics.AddError("create job", err.Error())
existingJob, getErr := r.client.GetJob(ctx, ns, plan.Name.ValueString()) return
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 сразу // Если RunID=0 — не ждём завершения, пишем state сразу
@@ -201,10 +187,10 @@ func (r *JobResource) Create(ctx context.Context, req resource.CreateRequest, re
EventJSON: plan.EventJSON, EventJSON: plan.EventJSON,
RunID: types.Int64Value(0), RunID: types.Int64Value(0),
WaitTimeoutSec: waitTimeoutSec, WaitTimeoutSec: waitTimeoutSec,
Phase: types.StringValue(createdJob.Phase), Phase: types.StringValue("Skipped"),
StartTime: types.StringValue(createdJob.StartTime), StartTime: types.StringValue(""),
CompletionTime: types.StringValue(createdJob.CompletionTime), CompletionTime: types.StringValue(""),
Message: types.StringValue(createdJob.Message), Message: types.StringValue("run_id=0: set run_id>0 to execute"),
})...) })...)
return return
} }