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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

printcolumns for kubectl get output
2026-03-07 08:24:33 +04:00
cairnr01probe abf40e8075 feat: operator-sdk scaffold with Function and Trigger CRDs
- operator-sdk init --domain kube5s.ru
- CRD: Function, Trigger (sless.kube5s.ru)
- controllers: FunctionReconciler, TriggerReconciler (stubs)
- internal/ structure: api, storage/postgres, storage/s3, builder, config
- fix: controller-tools bumped v0.11.1→v0.14.0 (Go 1.23 compat)
2026-03-07 08:16:39 +04:00
76 changed files with 4442 additions and 1289 deletions
+14
View File
@@ -46,6 +46,20 @@
---
## Именование
Имена должны быть **уникальными и осмысленными по всему проекту**:
- имена файлов
- имена функций/методов
- имена переменных/констант
- имена ресурсов (Terraform, Kubernetes и т.д.)
Цель: чтобы поиск по проекту находил нужные сущности без неоднозначности, а имя сразу отражало назначение.
Запрещены безликие и повторяющиеся имена вида `handler.py`, `handle`, `data`, `value`, `temp` без контекста.
---
## Git
Коммитить и пушить после каждого завершённого этапа.
+6 -1
View File
@@ -43,6 +43,9 @@ examples/*/dist/
**/handler.zip
test.token
*.tfvars
*.tfplan
plan.out
sless-plan
.e2e-logs/
.stress-logs/
@@ -57,6 +60,9 @@ test.token
**/dist/
# дополнительные вариации переменных/файлов конфигурации
*.tfvars.json
*.tfplan
plan.out
sless-plan
examples/.git
/deployments/k8s/operator-demo.yaml
/doc/decisions/log.md
@@ -64,4 +70,3 @@ examples/.git
/s3cfg_registry
/sqs-operator/quick_test.sh
/sqs-operator/sqs_load_test.sh
/.s3cfg_registry
-156
View File
@@ -1,156 +0,0 @@
// cmd/sless-plan/main.go
// Дата: 2026-03-12
// CLI-обёртка над `terraform plan`.
// Добавляет AI-анализ вывода если AI_HINT_LEVEL > 0.
// Существующий terraform plan не меняется — AI только дополняет вывод.
//
// Использование:
// sless-plan [terraform args...]
// sless-plan --ai-level=3 [terraform args...]
//
// Env-переменные:
// AI_HINT_LEVEL — уровень анализа 0–5 (0 = выключен, по умолчанию)
// AI_PROVIDER — провайдер: "groq" (по умолчанию), "google", "cloud"
// GROQ_API_KEY — API ключ Groq (рекомендуется, работает из РФ)
// GROQ_MODEL — модель Groq (по умолчанию "llama-3.3-70b-versatile")
// GROQ_ENDPOINT — endpoint (по умолчанию https://api.groq.com/openai/v1/chat/completions)
// GOOGLE_AI_API_KEY — API ключ Google Gemini (геоблок в РФ)
// GOOGLE_AI_MODEL — модель Gemini (по умолчанию "gemini-2.0-flash")
// CLOUD_LLM_ENDPOINT — endpoint будущего облачного LLM
// CLOUD_LLM_TOKEN — токен для облачного LLM
package main
import (
"bytes"
"context"
"flag"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"time"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/ai"
)
func main() {
// Флаг --ai-level переопределяет env AI_HINT_LEVEL
aiLevel := flag.Int("ai-level", -1, "уровень AI-анализа 0–5 (-1 = читать из AI_HINT_LEVEL)")
flag.Parse()
// Определяем уровень: флаг > env > 0
level := resolveLevel(*aiLevel)
// Собираем аргументы для terraform plan (всё что после флагов sless-plan)
tfArgs := append([]string{"plan"}, flag.Args()...)
// Запускаем terraform plan, захватывая вывод для AI
// но одновременно выводя его пользователю (tee-эффект)
planOutput, exitCode := runTerraform(tfArgs)
// Если уровень > 0 — запускаем AI анализ
if level > 0 {
runAIAnalysis(level, planOutput)
}
os.Exit(exitCode)
}
// resolveLevel определяет уровень AI анализа.
// Приоритет: --ai-level flag > AI_HINT_LEVEL env > 0
func resolveLevel(flagValue int) int {
if flagValue >= 0 {
// Флаг явно задан
if flagValue > 5 {
flagValue = 5
}
return flagValue
}
// Читаем из env
if envVal := os.Getenv("AI_HINT_LEVEL"); envVal != "" {
level, err := strconv.Atoi(envVal)
if err == nil && level >= 0 && level <= 5 {
return level
}
}
// TF_VAR_ai_hint_level тоже поддерживаем
if envVal := os.Getenv("TF_VAR_ai_hint_level"); envVal != "" {
level, err := strconv.Atoi(envVal)
if err == nil && level >= 0 && level <= 5 {
return level
}
}
return 0 // по умолчанию — AI выключен
}
// runTerraform запускает terraform с переданными аргументами.
// Вывод идёт напрямую в stdout/stderr пользователя И захватывается для AI.
// Возвращает текст вывода и код завершения.
func runTerraform(args []string) (string, int) {
cmd := exec.Command("terraform", args...)
// Захватываем stdout для AI, но также пишем напрямую в os.Stdout
var buf bytes.Buffer
cmd.Stdout = io.MultiWriter(os.Stdout, &buf)
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
err := cmd.Run()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return buf.String(), exitErr.ExitCode()
}
fmt.Fprintf(os.Stderr, "ошибка запуска terraform: %v\n", err)
return buf.String(), 1
}
return buf.String(), 0
}
// runAIAnalysis запускает AI анализ и выводит результат.
// Не прерывает работу если AI недоступен — только показывает предупреждение.
func runAIAnalysis(level int, planOutput string) {
cfg := ai.Config{
HintLevel: level,
Provider: getEnv("AI_PROVIDER", "groq"),
GroqAPIKey: os.Getenv("GROQ_API_KEY"),
GroqModel: getEnv("GROQ_MODEL", "llama-3.3-70b-versatile"),
GroqEndpoint: os.Getenv("GROQ_ENDPOINT"),
GoogleAPIKey: os.Getenv("GOOGLE_AI_API_KEY"),
GoogleModel: getEnv("GOOGLE_AI_MODEL", "gemini-2.0-flash"),
CloudEndpoint: os.Getenv("CLOUD_LLM_ENDPOINT"),
CloudToken: os.Getenv("CLOUD_LLM_TOKEN"),
}
analyzer, err := ai.NewAnalyzer(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "\n⚠ AI анализ недоступен: %v\n", err)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result, err := analyzer.Analyze(ctx, planOutput)
if err != nil {
fmt.Fprintf(os.Stderr, "\n⚠ AI анализ завершился с ошибкой: %v\n", err)
return
}
if result != "" {
fmt.Print(result)
}
}
// getEnv возвращает значение env-переменной или defaultVal если не задана.
func getEnv(key, defaultVal string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultVal
}
+7
View File
@@ -20,6 +20,13 @@ rules:
- get
- list
- watch
- apiGroups:
- ""
resources:
- secrets
verbs:
- create
- get
- apiGroups:
- ""
resources:
+38 -7
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-03-09 (feature B: захват stdout пода Job в status.Message)
// Изменено: 2026-03-17 20:00 (bugfix: job-name label удалён в k8s 1.27+, split-brain cached client)
// FunctionJobReconciler — контроллер одноразовых запусков функций.
// При создании FunctionJob:
// 1. Ждёт пока Function станет Ready
@@ -134,6 +134,13 @@ func (r *FunctionJobReconciler) Reconcile(ctx context.Context, req ctrl.Request)
// Автоудаление через 10 мин после завершения — чтобы не засорять кластер
TTLSecondsAfterFinished: &ttl,
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
"managed-by": "sless",
"functionjob": fj.Name,
"function": fn.Name,
},
},
Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyNever,
// Используем тот же образ что и Deployment функции
@@ -194,12 +201,19 @@ func (r *FunctionJobReconciler) syncJobStatus(ctx context.Context, fj *slessv1al
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseSucceeded
fj.Status.CompletionTime = &now
// Захватываем stdout пода — это return value функции (runner делает print(json.dumps(result)))
fj.Status.Message = getJobPodOutput(ctx, r.KubeClient, job.Namespace, job.Name)
fj.Status.Message = getJobPodOutput(ctx, r.KubeClient, job.Namespace, "functionjob="+fj.Name)
} else if job.Status.Failed > 0 {
now := metav1.Now()
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseFailed
fj.Status.CompletionTime = &now
fj.Status.Message = "job failed, check pod logs: kubectl logs -n sless-fn-" + fj.Namespace + " -l functionjob=" + fj.Name
// Захватываем логи по нашему лейблу functionjob= (работает во всех версиях k8s).
// Устаревший job-name= удалён в k8s 1.27+, batch.kubernetes.io/job-name= — только с 1.27.
podOutput := strings.TrimSpace(getJobPodOutput(ctx, r.KubeClient, job.Namespace, "functionjob="+fj.Name))
if podOutput == "" || podOutput == "completed successfully" {
fj.Status.Message = "job failed, check pod logs: kubectl logs -n " + job.Namespace + " -l functionjob=" + fj.Name
} else {
fj.Status.Message = "job failed: " + truncateForStatus(podOutput, 2000)
}
} else {
// Job ещё выполняется — перечитаем через 5 секунд
if err := r.Status().Update(ctx, fj); err != nil {
@@ -213,6 +227,17 @@ func (r *FunctionJobReconciler) syncJobStatus(ctx context.Context, fj *slessv1al
return ctrl.Result{}, nil
}
// truncateForStatus ограничивает длину текста для безопасной записи в status.message.
func truncateForStatus(message string, maxLen int) string {
if len(message) <= maxLen {
return message
}
if maxLen <= 3 {
return message[:maxLen]
}
return message[:maxLen-3] + "..."
}
// runtimeRunnerCommand возвращает CMD для запуска одноразового runner вместо HTTP-сервера.
// runner читает env SLESS_EVENT и SLESS_ENTRYPOINT, вызывает handle(event) один раз и завершается.
func runtimeRunnerCommand(runtime string) []string {
@@ -280,16 +305,22 @@ func goJobModeEnv(runtime string) []corev1.EnvVar {
return nil
}
// getJobPodOutput находит под созданный Job-ом и возвращает его stdout (trimmed).
// runner.py/runner.js печатают json.dumps(result) в stdout — это и есть return value функции.
// getJobPodOutput находит под по labelSelector и возвращает его stdout+stderr (trimmed).
// runner.py/runner.js печатают json.dumps(result) в stdout — return value функции.
// Исключения/трейсбэки Python/Node пишут в stderr — поэтому собираем оба потока.
// Если под не найден или логи недоступны — возвращает "completed successfully" как fallback.
func getJobPodOutput(ctx context.Context, kube kubernetes.Interface, namespace, jobName string) string {
// labelSelector передаётся снаружи — вызывающий код использует "functionjob=<name>" (наш лейбл,
// выставляется на PodTemplate контроллером и не зависит от версии k8s).
// НЕ использовать "job-name=" — этот встроенный лейбл удалён в k8s 1.27+ (у нас 1.34.1).
func getJobPodOutput(ctx context.Context, kube kubernetes.Interface, namespace, labelSelector string) string {
pods, err := kube.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
LabelSelector: "job-name=" + jobName,
LabelSelector: labelSelector,
})
if err != nil || len(pods.Items) == 0 {
return "completed successfully"
}
// Stdout: true, Stderr: true — собираем оба потока.
// Python исключения идут в stderr, runner.py пишет результат в stdout.
req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{})
stream, err := req.Stream(ctx)
if err != nil {
+107
View File
@@ -0,0 +1,107 @@
# 2026-03-18
# funcs-service.yaml — глобальный сервис листинга функций для всех пользователей.
# Развёртывается ОДИН РАЗ в namespace sless рядом с оператором.
# Доступен по: https://sless.kube5s.ru/funcs (с Bearer токеном пользователя)
#
# Обновить образ и применить:
# docker push naeel/sless-funcs-service:v0.1.0
# kubectl apply -f deployments/k8s/funcs-service.yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: sless-funcs-service
namespace: sless
labels:
app: sless-funcs-service
spec:
replicas: 1
selector:
matchLabels:
app: sless-funcs-service
template:
metadata:
labels:
app: sless-funcs-service
spec:
containers:
- name: funcs
image: naeel/sless-funcs-service:v0.1.3
ports:
- containerPort: 8090
env:
- name: SLESS_OPERATOR_URL
value: "http://sless-operator.sless.svc.cluster.local:9090"
- name: SLESS_EXTERNAL_URL
value: "https://sless.kube5s.ru"
# Системные функции, скрытые из листинга
- name: SLESS_EXCLUDE
value: "event-writer,event-monitor,event-cleaner"
# Токен сервиса задаётся через kubectl set env или Secret — не хранится в репо
# kubectl set env deployment/sless-funcs-service -n sless SLESS_SERVICE_TOKEN="$(cat secrets/test.token)"
- name: PORT
value: "8090"
livenessProbe:
httpGet:
path: /health
port: 8090
initialDelaySeconds: 5
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 8090
initialDelaySeconds: 3
periodSeconds: 10
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
cpu: 100m
memory: 64Mi
---
apiVersion: v1
kind: Service
metadata:
name: sless-funcs-service
namespace: sless
spec:
selector:
app: sless-funcs-service
ports:
- port: 8090
targetPort: 8090
---
# Отдельный Ingress для /funcs — nginx выбирает более специфичный путь перед /
# Без rewrite: сервис сам обрабатывает /funcs path
# TLS-сертификат sless-operator-tls уже управляется cert-manager через ingress оператора;
# здесь только ссылаемся на существующий секрет без аннотации cert-manager.io/cluster-issuer.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: sless-funcs-ingress
namespace: sless
annotations:
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
rules:
- host: sless.kube5s.ru
http:
paths:
- path: /funcs
pathType: Prefix
backend:
service:
name: sless-funcs-service
port:
number: 8090
tls:
- hosts:
- sless.kube5s.ru
secretName: sless-operator-tls
+122
View File
@@ -0,0 +1,122 @@
# Изменено: 2026-03-14
# Node-RED для визуального управления demo сценарием Event Log.
# Образ: nodered/node-red:4 (официальный, всегда есть на DockerHub)
# UI доступен по http://<ingress-ip>/nodered/
# Для AMQP нужен плагин node-red-contrib-amqp — ставится через initContainer.
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: nodered-data
namespace: sless
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nodered
namespace: sless
labels:
app: nodered
spec:
replicas: 1
selector:
matchLabels:
app: nodered
template:
metadata:
labels:
app: nodered
spec:
securityContext:
fsGroup: 1000
# initContainer устанавливает AMQP-плагин в PVC до старта основного контейнера
initContainers:
- name: install-nodes
image: nodered/node-red:latest
securityContext:
runAsUser: 0
runAsGroup: 0
command:
- sh
- -c
- |
chown -R 1000:1000 /data || true
cd /data
npm install --prefix /data node-red-contrib-amqp node-red-dashboard 2>&1 || true
volumeMounts:
- name: data
mountPath: /data
containers:
- name: nodered
image: nodered/node-red:latest
ports:
- containerPort: 1880
env:
- name: NODE_RED_ENABLE_PROJECTS
value: "false"
- name: TZ
value: "Europe/Moscow"
securityContext:
runAsUser: 1000
runAsGroup: 1000
volumeMounts:
- name: data
mountPath: /data
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
readinessProbe:
httpGet:
path: /
port: 1880
initialDelaySeconds: 15
periodSeconds: 10
volumes:
- name: data
persistentVolumeClaim:
claimName: nodered-data
---
apiVersion: v1
kind: Service
metadata:
name: nodered
namespace: sless
spec:
selector:
app: nodered
ports:
- port: 1880
targetPort: 1880
---
# Ingress на Node-RED — доступен снаружи по http://nodered.185.247.187.147.nip.io
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: nodered
namespace: sless
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
ingressClassName: nginx
rules:
- host: nodered.185.247.187.147.nip.io
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: nodered
port:
number: 1880
+6 -6
View File
@@ -3,9 +3,9 @@
# Состав:
# - ConfigMap: не-секретные env vars (S3_ENDPOINT, REGISTRY_HOST и т.д.)
# - Secret: секретные данные (S3 keys, postgres DSN, API token, Harbor pass)
# - Deployment: оператор naeel/sless-operator:v0.1.23 в namespace sless
# - Deployment: оператор naeel/sless-operator:v0.1.33 в namespace sless
# - Service: ClusterIP :9090 (REST API)
# - Ingress: sless-api.kube5s.ru → :9090 (внешний доступ с TLS)
# - Ingress: sless.kube5s.ru → :9090 (внешний доступ с TLS)
#
# Чтобы сменить registry — менять только REGISTRY_HOST в ConfigMap.
# Чтобы сменить Harbor-аккаунт — менять HARBOR_USER в ConfigMap + HARBOR_PASS в Secret.
@@ -32,7 +32,7 @@ data:
INGRESS_HOST: "fn.kube5s.ru"
# EXTERNAL_URL — если задан, URL функции = EXTERNAL_URL/fn/{namespace}/{name}
# Позволяет обойтись без wildcard DNS *.fn.kube5s.ru
EXTERNAL_URL: "https://sless-api.kube5s.ru"
EXTERNAL_URL: "https://sless.kube5s.ru"
---
# Secret создаётся отдельно через kubectl (не коммитить секреты в git!)
# Описание ключей:
@@ -72,7 +72,7 @@ spec:
containers:
- name: operator
# При обновлении версии оператора — менять тег здесь (не latest!)
image: naeel/sless-operator:v0.1.28
image: naeel/sless-operator:v0.1.33
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
imagePullPolicy: Always
ports:
@@ -133,7 +133,7 @@ metadata:
spec:
ingressClassName: nginx
rules:
- host: sless-api.kube5s.ru
- host: sless.kube5s.ru
http:
paths:
- path: /
@@ -145,5 +145,5 @@ spec:
number: 9090
tls:
- hosts:
- sless-api.kube5s.ru
- sless.kube5s.ru
secretName: sless-operator-tls
+73
View File
@@ -0,0 +1,73 @@
# Изменено: 2026-03-14
# RabbitMQ для demo сценария Event Log.
# Используется официальный образ с management-плагином для веб-UI.
# credentials: sless / sless123
# AMQP: amqp://sless:sless123@rabbitmq.sless.svc.cluster.local:5672/
# Management UI: http://rabbitmq.sless.svc.cluster.local:15672/
---
apiVersion: v1
kind: Secret
metadata:
name: rabbitmq-secret
namespace: sless
stringData:
RABBITMQ_DEFAULT_USER: "sless"
RABBITMQ_DEFAULT_PASS: "sless123"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: rabbitmq
namespace: sless
labels:
app: rabbitmq
spec:
replicas: 1
selector:
matchLabels:
app: rabbitmq
template:
metadata:
labels:
app: rabbitmq
spec:
containers:
- name: rabbitmq
image: rabbitmq:3.13-management-alpine
ports:
- name: amqp
containerPort: 5672
- name: management
containerPort: 15672
envFrom:
- secretRef:
name: rabbitmq-secret
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
readinessProbe:
tcpSocket:
port: 5672
initialDelaySeconds: 40
periodSeconds: 10
timeoutSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: rabbitmq
namespace: sless
spec:
selector:
app: rabbitmq
ports:
- name: amqp
port: 5672
targetPort: 5672
- name: management
port: 15672
targetPort: 15672
+38 -8
View File
@@ -1,5 +1,7 @@
# API Design
Последнее обновление: 2026-03-18
## Базовый URL
```
@@ -7,15 +9,38 @@ http://<operator-host>:9090/v1
```
Локально: `http://localhost:9090/v1`
В кластере (будущее): `http://sless-operator.sless.svc.cluster.local:9090/v1`
В кластере: `http://sless-operator.sless.svc.cluster.local:9090/v1`
Публично: `https://sless.kube5s.ru/v1/...` (через Ingress)
**Реализовано** (текущий namespace-aware формат):
**Реализованные эндпоинты:**
```
/v1/namespaces/{namespace}/functions[/{name}]
/v1/namespaces/{namespace}/functions/{name}/upload
/v1/namespaces/{namespace}/triggers[/{name}]
/v1/namespaces/{namespace}/functions/{name}/invocations
GET /v1/namespaces/{ns}/functions
POST /v1/namespaces/{ns}/functions
GET /v1/namespaces/{ns}/functions/{name}
PUT /v1/namespaces/{ns}/functions/{name}
DELETE /v1/namespaces/{ns}/functions/{name}
POST /v1/namespaces/{ns}/functions/{name}/upload
GET /v1/namespaces/{ns}/functions/{name}/source ← файлы кода из S3 tar.gz (JSON)
GET /v1/namespaces/{ns}/functions/{name}/invocations
GET /v1/namespaces/{ns}/triggers
POST /v1/namespaces/{ns}/triggers
GET /v1/namespaces/{ns}/triggers/{name}
PATCH /v1/namespaces/{ns}/triggers/{name} ← {"enabled": bool}
DELETE /v1/namespaces/{ns}/triggers/{name}
POST /v1/namespaces/{ns}/jobs
GET /v1/namespaces/{ns}/jobs/{name}
DELETE /v1/namespaces/{ns}/jobs/{name}
```
**Глобальный сервис funcs (не оператор):**
```
GET https://sless.kube5s.ru/funcs/<namespace> ← plain text (курл) / HTML (браузер)
GET https://sless.kube5s.ru/funcs?token=<jwt> ← редирект по namespace
GET https://sless.kube5s.ru/funcs/<namespace>/source/<fn> ← прокси к GET /source
PATCH https://sless.kube5s.ru/funcs/<namespace>/triggers/<name> ← прокси к PATCH /triggers
GET https://sless.kube5s.ru/health ← liveness probe
```
## Аутентификация
@@ -24,6 +49,11 @@ http://<operator-host>:9090/v1
Authorization: Bearer <cloud-token>
```
Токен — JWT от `auth-api`. Middleware в операторе:
1. Извлекает `sub` из payload (без проверки подписи — доверяет Ingress)
2. Вычисляет namespace: `SHA256(sub)[:8]` hex → `sless-{16 hex символов}`
3. Проверяет что запрошенный `{namespace}` совпадает с вычисленным
## Ресурсы
### Functions
@@ -87,8 +117,8 @@ field: code = <zip-file>
## Поддерживаемые runtime (v1)
- `python3.11` — реализован и протестирован
- `go1.21` — планируется
- `nodejs20` — планируется
- `nodejs20` — реализован и протестирован
- `go1.23` — реализован
## Модель Function
@@ -0,0 +1,368 @@
# Agent Handoff — 2026-03-18 (финальное состояние сессии)
Этот файл — полный срез для нового агента: что сделано, как устроено, как работать.
---
## 1. Идентификация проекта
- **Репозиторий:** `gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless`
- **Локальная копия:** `/home/naeel/remote_dev/sless/`
- **Remote server:** `naeel@5.172.178.213` (workspace: `~/terra/sless/`)
- **SSH ключ:** `/home/naeel/remote_dev/sless/secrets/naeel_vm_id_ed25519`
- **SSH команда:** `ssh -i <ключ> -o StrictHostKeyChecking=no naeel@5.172.178.213`
- **Активная ветка:** `feat/web-console` (последний коммит `a04dfb2`)
- **Git origin:** `https://gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless.git`
### Важно про git
Git **не работает локально** (зависает при записи объектов из-за NFS-подобного поведения volume).
Все `git add / commit / push` — **только через SSH на remote machine**.
`scp` для копирования файлов → `ssh` для git-операций.
---
## 2. Что такое проект
Managed Serverless Functions Service для облачного провайдера **nubes.ru**.
Пользователь пишет `main.tf` с ресурсами `sless_function`, `sless_trigger`, `sless_job`.
Terraform провайдер собирает zip → загружает в оператор → оператор запускает kaniko → Docker образ → Deployment в k8s.
**Внешнее API оператора:** `https://sless.kube5s.ru`
**Ingress IP:** `185.247.187.147`
**Namespace оператора в k8s:** `sless`
---
## 3. Стек и компоненты
| Компонент | Технология | Namespace / где |
|-----------|-----------|-----------------|
| Operator (API + Controllers) | Go (controller-runtime) | k8s namespace `sless` |
| funcs-service (web-консоль) | Go (net/http) | k8s namespace `sless` |
| PostgreSQL | PostgreSQL 16 | k8s namespace `sless` |
| S3 | Ceph (облачный) | `s3.msk-1.ngcloud.ru` |
| Container Registry | DockerHub (`naeel/`) | внешний |
| Builder | kaniko Job | namespace пользователя |
| Function (HTTP trigger) | k8s Deployment + Service | namespace пользователя |
| Function (one-shot) | k8s Job | namespace пользователя |
| Function (cron) | k8s CronJob | namespace пользователя |
| Terraform Provider | Go (plugin-framework v6) | localhost/CI |
---
## 4. Текущие версии образов
| Образ | Версия | Что внутри |
|-------|--------|-----------|
| `naeel/sless-operator` | **v0.1.34** | REST API + k8s controllers; GET /source; proxy-готовый PATCH /triggers |
| `naeel/sless-funcs-service` | **v0.2.0** | HTML web-консоль + plain text (backward compat) |
| `naeel/sless-runtime-python3.11` | **v0.1.3** | str return → text/plain |
| `naeel/sless-runtime-nodejs20` | **v0.1.2** | без изменений |
| `naeel/sless-runtime-go1.23` | **v0.1.0** | без изменений |
---
## 5. Структура директорий (актуальная)
```
sless/
├── main.go # точка входа оператора (controller-runtime + HTTP сервер)
├── Dockerfile # сборка оператора
├── go.mod # module: gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless
├── api/v1alpha1/ # CRD типы: Function, FunctionJob, Trigger
│ ├── function_types.go
│ ├── job_types.go
│ └── trigger_types.go
├── controllers/ # k8s reconcilers
│ ├── function_controller.go # Function CRD → kaniko → Deployment/Service
│ ├── functionjob_controller.go # FunctionJob CRD → k8s Job → собирает stdout/stderr
│ └── trigger_controller.go # Trigger CRD → Deployment scale / CronJob
├── internal/
│ ├── api/
│ │ ├── router.go # gorilla/mux: все REST маршруты (актуальный)
│ │ ├── middleware/ # Auth (JWT→namespace), Logging
│ │ └── handler/
│ │ ├── handler.go # Handler struct (K8s, S3, PG, Log)
│ │ ├── functions.go # CRUD Functions
│ │ ├── triggers.go # CRUD Triggers + UpdateTrigger (PATCH enabled)
│ │ ├── upload.go # POST /upload — zip → tar.gz → S3 → Function CRD patch
│ │ ├── source.go # GET /source — tar.gz из S3 → JSON файлы (НОВЫЙ)
│ │ ├── jobs.go # CRUD FunctionJobs
│ │ ├── invocations.go # logs из Postgres
│ │ ├── invoke.go # прокси вызова HTTP функций
│ │ └── namespace.go # EnsureNamespace
│ ├── builder/
│ │ └── context.go # zip + runtime → tar.gz + Dockerfile для kaniko
│ ├── storage/
│ │ ├── s3/client.go # minio-go: Upload, UploadContext, Download, Delete
│ │ └── postgres/ # хранение invocation logs
│ └── config/ # env vars конфиг
├── services/
│ └── funcs/
│ ├── main.go # web-консоль сервис (v0.2.0)
│ ├── index.html # HTML шаблон (embed)
│ ├── Dockerfile # multi-stage Go → alpine
│ └── funcs-service.yaml # (дубль, не деплоится отсюда)
├── deployments/k8s/
│ ├── operator.yaml # ConfigMap + Secret + Deployment + Service + Ingress оператора
│ ├── funcs-service.yaml # Deployment + Service + Ingress funcs-service
│ ├── postgres.yaml # PostgreSQL
│ └── rbac.yaml # ClusterRole для оператора
├── terraform/provider/ # terraform-provider-sless
│ ├── main.go
│ └── internal/
│ ├── client/client.go # HTTP клиент к оператору
│ └── resources/
│ ├── function_resource.go # sless_function: source_dir→zip, code_hash, ModifyPlan
│ ├── trigger_resource.go # sless_trigger
│ └── job_resource.go # sless_job + ErrJobAlreadyExists handling
├── runtimes/
│ ├── python3.11/server.py # HTTP wrapper (str → text/plain)
│ ├── nodejs20/ # HTTP wrapper
│ └── go1.23/ # multi-stage builder образ
├── examples/
│ ├── POSTGRES/ # pg функции: create-table, pg-info, pg-table-reader
│ ├── hello-go/
│ ├── hello-node/
│ └── ...
├── migrations/001_initial.sql # PostgreSQL схема
└── doc/ # ← ты здесь
├── architecture/
│ └── agent-handoff-2026-03-18.md ← ЭТОТ ФАЙЛ
├── api/design.md
├── decisions/log.md
├── errors/log.md
└── progress.md
```
---
## 6. REST API оператора — полный список маршрутов
Все `/v1/` защищены JWT (middleware.Auth проверяет Bearer токен + namespace).
`/fn/` — публичный прокси для HTTP-триггеров (без auth).
```
POST /v1/namespaces/{ns}/ensure # создать namespace (идемпотентно)
GET /v1/namespaces/{ns}/functions # список функций
POST /v1/namespaces/{ns}/functions # создать функцию
GET /v1/namespaces/{ns}/functions/{name} # получить функцию
PUT /v1/namespaces/{ns}/functions/{name} # обновить функцию
DELETE /v1/namespaces/{ns}/functions/{name} # удалить функцию
POST /v1/namespaces/{ns}/functions/{name}/upload # загрузить zip → S3 → kaniko
GET /v1/namespaces/{ns}/functions/{name}/source # НОВЫЙ: файлы кода из S3 (JSON)
GET /v1/namespaces/{ns}/functions/{name}/invocations # логи вызовов
GET /v1/namespaces/{ns}/triggers # список триггеров
POST /v1/namespaces/{ns}/triggers # создать триггер
GET /v1/namespaces/{ns}/triggers/{name} # получить триггер
PATCH /v1/namespaces/{ns}/triggers/{name} # enable/disable: {"enabled": bool}
DELETE /v1/namespaces/{ns}/triggers/{name} # удалить триггер
POST /v1/namespaces/{ns}/jobs # создать FunctionJob
GET /v1/namespaces/{ns}/jobs/{name} # получить FunctionJob
DELETE /v1/namespaces/{ns}/jobs/{name} # удалить FunctionJob
GET/POST/... /fn/{namespace}/{name}[/...] # вызов HTTP функции (без auth)
```
---
## 7. funcs-service — маршруты
Сервис `sless-funcs-service`, namespace `sless`, порт 8090.
Ingress: `sless.kube5s.ru/funcs` → `sless-funcs-service:8090`.
```
GET /health # liveness/readiness probe (без auth)
GET /funcs # usage hint (нет токена → 401 с подсказкой)
GET /funcs?token=<jwt> # листинг через JWT
GET /funcs/<namespace> # листинг по namespace (браузер→HTML, curl→plain text)
GET /funcs/<namespace>/source/<fn> # прокси → оператор GET /source (serviceToken)
PATCH /funcs/<namespace>/triggers/<name> # прокси → оператор PATCH /triggers (serviceToken)
```
Логика переключения HTML/plain text: `strings.Contains(Accept header, "text/html")`.
Браузер всегда шлёт `text/html` в Accept → HTML консоль.
`curl` без `-H "Accept: text/html"` → plain text (совместимость с v0.1.x).
**Env vars funcs-service:**
```
SLESS_OPERATOR_URL = http://sless-operator.sless.svc.cluster.local:9090
SLESS_EXTERNAL_URL = https://sless.kube5s.ru
SLESS_EXCLUDE = (список функций скрытых из листинга, через запятую)
SLESS_SERVICE_TOKEN = <JWT> (задаётся через kubectl set env, НЕ в git)
PORT = 8090
```
---
## 8. Namespace пользователя
```
JWT.sub (UUID) → SHA256(sub)[:8] → hex → "sless-" + 16 hex символов
```
Пример: sub `019cc268-6c6a-781e-8613-4bed4ec7cd20` → namespace `sless-ffd1f598c169b0ae`
Эта логика **одинакова** в трёх местах:
- `internal/api/middleware/auth.go` (оператор)
- `services/funcs/main.go` (funcs-service)
- `terraform/provider/internal/client/client.go` (провайдер)
---
## 9. S3 хранение кода — ключи
При `POST /upload` создаются **два объекта:**
```
functions/{ns}/{name}/{timestamp}.zip ← исходный код (zip от пользователя)
contexts/{ns}/{name}/{timestamp}.tar.gz ← build context для kaniko (zip + Dockerfile)
```
`Function.Spec.S3Key` хранит путь к `contexts/...`.
`GET /source` читает `Function.Spec.S3Key`, скачивает tar.gz, извлекает файлы без Dockerfile.
**Важно:** zip исходника (`functions/...`) отдельно не хранится в CRD.
Код источника берётся из tar.gz контекста — там те же файлы пользователя.
---
## 10. Terraform провайдер — sless_function
```hcl
resource "sless_function" "my_func" {
name = "my-func"
runtime = "python3.11" # python3.11 | nodejs20 | go1.23
entrypoint = "handler.handle"
source_dir = "${path.module}/code/my-func" # директория → провайдер делает zip сам
# ИЛИ:
# code_path = "./handler.zip" # готовый zip
# code_hash = filesha256("./handler.zip") # для детекции изменений
memory_mb = 128
timeout_sec = 30
env_vars = { KEY = "value" }
build_timeout_sec = 300 # ожидание kaniko (дефолт 300 сек)
}
```
При изменении файлов в `source_dir`:
- `terraform plan` → пересчитывает `code_hash` (ModifyPlan), показывает diff
- `terraform apply` → загружает новый zip, ждёт сборки → `phase = Ready`
- Новый код виден в браузере сразу после apply
---
## 11. Где что запущено (kubectl)
```bash
# Проверить поды оператора
kubectl get pods -n sless
# Проверить версию образа оператора
kubectl get deployment sless-operator -n sless -o jsonpath='{.spec.template.spec.containers[0].image}'
# Посмотреть логи оператора
kubectl logs -n sless -l app=sless-operator --tail=50
# Посмотреть логи funcs-service
kubectl logs -n sless -l app=sless-funcs-service --tail=50
# Обновить образ оператора
kubectl set image deployment/sless-operator operator=naeel/sless-operator:vX.X.X -n sless
kubectl rollout status deployment/sless-operator -n sless --timeout=90s
# Обновить образ funcs-service
kubectl set image deployment/sless-funcs-service funcs=naeel/sless-funcs-service:vX.X.X -n sless
# Посмотреть функции пользователя
kubectl get functions -n sless-ffd1f598c169b0ae
# Посмотреть триггеры
kubectl get triggers -n sless-ffd1f598c169b0ae
# Обновить SLESS_SERVICE_TOKEN (не хранится в git!)
kubectl set env deployment/sless-funcs-service -n sless SLESS_SERVICE_TOKEN=<jwt>
```
---
## 12. Тестовые данные
- **Тестовый токен:** `/home/naeel/remote_dev/sless/secrets/test.token`
- **Namespace тест-пользователя:** `sless-ffd1f598c169b0ae`
- **Рабочие функции:**
- `pg-info` (nodejs20) — читает версию PostgreSQL и счётчик строк
- `pg-table-reader` (python3.11) — читает строки из таблицы
- `pg-create-table-runner` (python3.11) — job: создаёт таблицу
- **Быстрый тест:**
```bash
# plain text список (curl)
curl https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae
# HTML консоль (браузер или curl с Accept)
curl -H "Accept: text/html" https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae
# исходный код функции
curl https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae/source/pg-info
# вызов функции
curl https://sless.kube5s.ru/fn/sless-ffd1f598c169b0ae/pg-info
```
---
## 13. Деплой нового образа — стандартный workflow
```bash
SSH="ssh -i /home/naeel/remote_dev/sless/secrets/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213"
SCP="scp -i /home/naeel/remote_dev/sless/secrets/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no"
# 1. Скопировать изменённые файлы оператора на remote
$SCP /home/naeel/remote_dev/sless/path/to/file.go naeel@5.172.178.213:~/terra/sless/path/to/file.go
# 2. Собрать образ на remote
$SSH "cd ~/terra/sless && docker build -t naeel/sless-operator:vX.X.X . 2>&1 | tail -5"
# 3. Запушить
$SSH "docker push naeel/sless-operator:vX.X.X 2>&1 | tail -3"
# 4. Задеплоить
$SSH "kubectl set image deployment/sless-operator operator=naeel/sless-operator:vX.X.X -n sless && kubectl rollout status deployment/sless-operator -n sless --timeout=90s"
# 5. Коммит (через SSH! не локально)
$SSH "cd ~/terra/sless && git add <файлы> && git commit -m 'msg' && git push origin feat/web-console"
```
Для funcs-service: путь `~/terra/sless/services/funcs/`, контейнер называется `funcs`.
---
## 14. Известные проблемы / особенности
| Проблема | Решение |
|---------|---------|
| `git` зависает локально | Только через SSH на remote machine |
| `SLESS_SERVICE_TOKEN` не в git | Задан через `kubectl set env`, при пересоздании пода — пропадёт! Нужно переставить вручную |
| `job-name=` label удалён в k8s 1.27+ | Используем свой label `functionjob=<name>` на PodTemplate (исправлено в v0.1.34) |
| S3 endpoint с SSL | `useSSL=false` для внутреннего s3, `useSSL=true` для облачного `s3.msk-1.ngcloud.ru` |
| `python3.11`: str return → text/plain | Начиная с `naeel/sless-runtime-python3.11:v0.1.3` |
---
## 15. Следующие возможные задачи (не начаты)
| Задача | Сложность | Заметки |
|--------|-----------|---------|
| Слияние `feat/web-console` в `main` (или базовую ветку) | низкая | Ветка стабильная, все тесты проходят |
| Кнопка "Обновить код" в HTML консоли (upload из браузера) | средняя | Drag&drop zip или указать source_dir |
| Обратная синхронизация (скачать код из S3 в source_dir) | средняя | terraform data source или отдельная команда |
| History/versioning (несколько версий кода) | высокая | S3 уже хранит по timestamp — нужен UI |
| SLESS_SERVICE_TOKEN из k8s Secret | низкая | Сейчас задаётся через kubectl set env — надо в YAML (sealed secret) |
| Логи функции в HTML консоли | средняя | GET /invocations уже есть в операторе |
| Публикация terraform провайдера | средняя | Terraform Registry или Gitea Releases |
+40 -1
View File
@@ -1,6 +1,6 @@
# Архитектура системы
Последнее обновление: 2026-03-11 (v0.1.22)
Последнее обновление: 2026-03-18 (v0.1.34 + funcs-service v0.2.0)
## Общее описание
@@ -13,6 +13,7 @@ Managed Serverless Functions Service для облачного провайде
| Компонент | Технология | Где запущен |
|-----------|-----------|-------------|
| Operator (API + Controllers) | Go (controller-runtime) | Kubernetes, namespace `sless` |
| funcs-service (глобальная консоль) | Go (net/http) | Kubernetes, namespace `sless` |
| PostgreSQL | PostgreSQL 16 | Kubernetes, namespace `sless` |
| S3 | Ceph (облачный) | `s3.msk-1.ngcloud.ru` |
| Container Registry | DockerHub (`naeel/`) | внешний |
@@ -25,6 +26,44 @@ Managed Serverless Functions Service для облачного провайде
> Redis и RabbitMQ — отложены до v2.
## Компонент: funcs-service
Глобальный HTTP сервис — **одна копия** на весь кластер, для всех пользователей.
```
User Browser / curl
└─► https://sless.kube5s.ru/funcs/<namespace>
└─► nginx Ingress (sless-funcs-ingress)
└─► sless-funcs-service:8090 (namespace sless)
└─► http://sless-operator.sless.svc.cluster.local:9090/v1/...
```
**Файлы:**
- `services/funcs/main.go` — логика
- `services/funcs/Dockerfile` — multi-stage Go → alpine
- `deployments/k8s/funcs-service.yaml` — Deployment + Service + Ingress
**Env vars сервиса:**
| Переменная | Значение |
|-----------|---------|
| `SLESS_OPERATOR_URL` | `http://sless-operator.sless.svc.cluster.local:9090` |
| `SLESS_EXTERNAL_URL` | `https://sless.kube5s.ru` |
| `SLESS_EXCLUDE` | `event-writer,event-monitor,event-cleaner` |
| `SLESS_SERVICE_TOKEN` | JWT токен (задаётся через `kubectl set env`, не в git) |
## Хранение кода функций в S3
```
Terraform source_dir (локально)
└─► zip → POST /upload → builder.PrepareContext()
├─► functions/{ns}/{name}/{ts}.zip ← ИСХОДНЫЙ КОД пользователя
└─► contexts/{ns}/{name}/{ts}.tar.gz ← BUILD CONTEXT для kaniko
└─► Function CRD: spec.s3Key = "contexts/..."
└─► контроллер → kaniko Job → Docker image
```
Для web-консоли: `GET /source` читает `contexts/{ns}/{name}/{ts}.tar.gz` (из `Function.Spec.S3Key`), распаковывает tar.gz, фильтрует Dockerfile, возвращает пользовательские файлы.
## Изоляция пользователей — Namespace per user
Каждый пользователь облака получает **отдельный k8s namespace**.
+90 -33
View File
@@ -1,49 +1,106 @@
# Структура проекта
Последнее обновление: 2026-03-18 (v0.1.34)
## Репозиторий
`gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless`
## Директории
## Директории (актуальное состояние)
```
sless/
├── cmd/
│ └── api/
│ └── main.go # точка входа API сервера
├── main.go # точка входа оператора (controller-runtime + HTTP сервер)
├── Dockerfile # multi-stage сборка оператора
├── go.mod # module: gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless
├── Makefile
├── api/
│ └── v1alpha1/ # CRD типы для controller-gen
│ ├── function_types.go # Function CRD: spec, status
│ ├── job_types.go # FunctionJob CRD
│ ├── trigger_types.go # Trigger CRD
│ └── zz_generated.deepcopy.go # автогенерация DeepCopy
├── controllers/
│ ├── function_controller.go # Function → kaniko Job → Deployment/Service
│ ├── functionjob_controller.go # FunctionJob → k8s Job → stdout/stderr → status
│ └── trigger_controller.go # Trigger → Deployment scale / CronJob
├── internal/
│ ├── api/
│ │ ├── handler/ # HTTP хендлеры (functions, versions, triggers)
│ │ ├── middleware/ # auth, logging, rate limit
│ │ └── router.go # регистрация маршрутов
│ ├── model/ # доменные модели: Function, Version, Trigger, Invocation
│ │ ├── router.go # gorilla/mux: регистрация всех маршрутов
│ │ ├── middleware/
│ │ │ ├── auth.go # JWT Bearer → namespace (SHA256)
│ │ │ └── logging.go # access log
│ │ └── handler/
│ │ ├── handler.go # Handler struct (K8s, S3, PG, Log)
│ │ ├── functions.go # GET/POST/PUT/DELETE функций
│ │ ├── triggers.go # GET/POST/PATCH/DELETE триггеров
│ │ ├── upload.go # POST /upload: zip → tar.gz → S3 → CRD patch
│ │ ├── source.go # GET /source: S3 tar.gz → JSON файлы (НОВЫЙ v0.1.34)
│ │ ├── jobs.go # GET/POST/DELETE FunctionJob
│ │ ├── invocations.go # GET /invocations: логи из Postgres
│ │ ├── invoke.go # прокси HTTP вызовов функций
│ │ └── namespace.go # POST /ensure: создать k8s namespace
│ ├── builder/
│ │ └── context.go # zip + runtime → tar.gz + Dockerfile для kaniko
│ ├── storage/
│ │ ├── postgres/ # CRUD функций, версий, логов вызовов
│ │ └── s3/ # загрузка/скачивание zip архивов кода
│ ├── builder/ # сборка Docker образа из кода пользователя
│ ├── runner/ # запуск функций в k8s (Jobs / Deployments)
│ └── config/ # конфиг из env переменных
├── migrations/ # SQL миграции (numbered: 001_, 002_, ...)
├── deployments/
│ └── k8s/ # манифесты: Deployment, Service, Ingress, RBAC
├── api/
│ └── openapi.yaml # OpenAPI 3.0 спецификация
├── doc/ # документация проекта (эта папка)
└── docker-compose.yml # локальная разработка: postgres, redis, minio
│ │ ├── s3/client.go # minio-go: Upload, UploadContext, Download, Delete
│ │ └── postgres/ # хранение invocation logs
│ └── config/ # конфиг из env переменных
├── services/
│ └── funcs/ # отдельный HTTP сервис (web-консоль)
│ ├── main.go # v0.2.0: HTML+plain text+proxy к оператору
│ ├── index.html # dark-themed HTML консоль (go:embed)
│ ├── Dockerfile # multi-stage Go → alpine (копирует main.go + index.html)
│ └── go.mod # отдельный Go модуль
├── migrations/
│ └── 001_initial.sql # схема PostgreSQL (invocation logs)
├── deployments/k8s/
│ ├── operator.yaml # ConfigMap + Deployment + Service + Ingress оператора
│ ├── funcs-service.yaml # Deployment + Service + Ingress funcs-service
│ ├── postgres.yaml # PostgreSQL
│ └── rbac.yaml # ClusterRole + ClusterRoleBinding для оператора
├── runtimes/
│ ├── python3.11/server.py # HTTP wrapper (str → text/plain начиная с v0.1.3)
│ ├── nodejs20/server.js # HTTP wrapper
│ └── go1.23/ # multi-stage builder образ
├── terraform/
│ └── provider/ # terraform-provider-sless (отдельный Go модуль)
│ ├── main.go
│ └── internal/
│ ├── client/client.go # REST клиент к оператору
│ ├── resources/
│ │ ├── function_resource.go # sless_function: source_dir, code_hash, ModifyPlan
│ │ ├── trigger_resource.go # sless_trigger
│ │ └── job_resource.go # sless_job + ErrJobAlreadyExists
│ └── provider/provider.go
├── examples/
│ ├── POSTGRES/ # pg функции: create-table, pg-info, pg-table-reader
│ ├── hello-go/
│ ├── hello-node/
│ └── simple-python/
├── config/ # kustomize конфиги (controller-gen артефакты)
│ ├── crd/ # CRD манифесты (автогенерация)
│ ├── rbac/
│ └── samples/
├── hack/
│ └── boilerplate.go.txt # шаблон заголовков файлов
└── doc/ # документация проекта
├── architecture/
│ ├── agent-handoff-2026-03-18.md # полный контекст для нового агента
│ ├── overview.md # архитектура системы
│ └── project-structure.md # этот файл
├── api/design.md # дизайн REST API
├── decisions/log.md # журнал архитектурных решений
├── errors/log.md # известные ошибки и решения
└── progress.md # трекер задач
```
## Go module
## Go модули
```
module gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless
```
Два независимых Go модуля в одном репозитории:
## Порядок разработки
1. `internal/config` + `internal/model` — базовые структуры данных
2. `migrations/` + `internal/storage/postgres` — схема БД и CRUD
3. `internal/api` — HTTP хендлеры, роутер, middleware
4. `internal/storage/s3` — загрузка кода функций
5. `internal/builder` — сборка Docker образов
6. `internal/runner` — запуск функций в k8s
7. `deployments/k8s` — манифесты для деплоя
| Модуль | Путь | Назначение |
|--------|------|-----------|
| `gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless` | `/` | Оператор + API |
| `gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/services/funcs` | `services/funcs/` | Web-консоль сервис |
| `terra.k8c.ru/naeel/sless` | `terraform/provider/` | Terraform провайдер |
-269
View File
@@ -1,269 +0,0 @@
# План: AI-анализ terraform plan (уровни 0–5)
Дата: 2026-03-12
Статус: черновик / на согласование
---
## 1. Суть задачи
После выполнения `terraform plan` отправлять вывод на анализ в LLM.
Глубина анализа управляется переменной `ai_hint_level` (0–5) в Terraform-конфигурации.
Сейчас — Google AI (Gemini API). В будущем — собственный облачный LLM (drop-in замена).
---
## 2. Уровни подсказок (`ai_hint_level`)
| Уровень | Название | Что делает |
|---------|----------------|-------------------------------------------------------------------|
| 0 | OFF | Ничего. AI не вызывается. `terraform plan` работает как обычно. |
| 1 | SYNTAX | Проверка синтаксиса: ошибки в HCL, опечатки, невалидные блоки. |
| 2 | BASIC | + базовый анализ: неиспользуемые ресурсы, пустые значения. |
| 3 | MODERATE | + анализ зависимостей, потенциальные конфликты, порядок apply. |
| 4 | DETAILED | + best practices, безопасность (открытые порты, широкие IAM). |
| 5 | FULL | Полные рекомендации: оптимизация, рефакторинг, альтернативы. |
---
## 3. Как задаётся уровень
**По умолчанию AI не активен и ничего не показывает.** Если `ai_hint_level` нигде не задан — уровень 0, `sless plan` работает как обычный `terraform plan`, без единого упоминания об AI.
Уровень задаётся **только явно**, одним из способов (приоритет сверху вниз):
1. Env-переменная (рекомендуется):
```bash
export TF_VAR_ai_hint_level=3
```
2. В `variables.tf` пользовательского проекта (опционально, только если нужно зафиксировать уровень в коде):
```hcl
variable "ai_hint_level" {
description = "Уровень AI-анализа terraform plan (0=off, 5=full)"
type = number
default = 0
validation {
condition = var.ai_hint_level >= 0 && var.ai_hint_level <= 5
error_message = "ai_hint_level должен быть от 0 до 5"
}
}
```
3. CLI-флаг напрямую:
```bash
sless plan --ai-level=3
```
**Важно:** CLI сам читает уровень из env `TF_VAR_ai_hint_level` или флага `--ai-level`. Если ни того ни другого нет — молча применяет уровень 0. Никакой переменной в манифестах не требуется. Пользователь не видит AI, пока не включит его явно.
---
## 4. Архитектура решения
```
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ terraform │────>│ wrapper-скрипт │────>│ AI Analyzer │
│ plan │ │ (bash/Go CLI) │ │ (Go сервис) │
│ (вывод JSON)│ │ │ │ │
└─────────────┘ └──────────────────┘ └────────┬────────┘
│
┌────────▼────────┐
│ LLM Backend │
│ (Google Gemini) │
│ → Cloud LLM │
└─────────────────┘
```
### Компоненты:
1. **Wrapper-скрипт / CLI** — вызывает `terraform plan -json`, читает `ai_hint_level`, передаёт вывод в анализатор.
2. **AI Analyzer (Go)** — формирует промпт по уровню, отправляет в LLM, форматирует ответ.
3. **LLM Backend** — подключаемый через интерфейс (сначала Google, потом облачный).
---
## 5. Реализация по шагам
### Шаг 1: CLI-обёртка (`sless-plan`)
Новый бинарник или shell-скрипт, который:
- Запускает `terraform plan -json -out=plan.bin`
- Читает `ai_hint_level` из state/variables (через `terraform output` или парсинг .tf)
- Если level > 0, передаёт JSON-вывод плана в AI Analyzer
- Выводит стандартный plan + AI-рекомендации ниже
```bash
# Пользователь вызывает:
sless plan # вместо terraform plan
# или:
terraform plan && sless analyze --level=3
```
### Шаг 2: AI Analyzer сервис (Go)
Расположение: `internal/ai/` или отдельный `cmd/ai-analyzer/`
```
internal/ai/
├── analyzer.go # основная логика: формирование промпта, парсинг ответа
├── provider.go # интерфейс LLM-провайдера
├── google_gemini.go # реализация для Google Gemini API
├── cloud_llm.go # (заглушка) реализация для будущего облачного LLM
└── prompts.go # шаблоны промптов по уровням 1–5
```
Ключевой интерфейс:
```go
// LLMProvider — абстракция над любым LLM-бэкендом.
// Сейчас: Google Gemini. В будущем: облачный LLM (drop-in замена).
type LLMProvider interface {
Analyze(ctx context.Context, prompt string) (string, error)
Name() string
}
```
### Шаг 3: Промпты по уровням
Каждый уровень = свой system prompt + ограничения:
| Уровень | System prompt (суть) |
|---------|-----------------------------------------------------------|
| 1 | "Проверь только синтаксис HCL. Ничего лишнего." |
| 2 | "Синтаксис + базовые проблемы (unused, empty values)." |
| 3 | "Анализ зависимостей, порядок, потенциальные конфликты." |
| 4 | "Best practices, безопасность, IAM, открытые порты." |
| 5 | "Полный аудит: оптимизация, рефакторинг, альтернативы." |
### Шаг 4: Google Gemini интеграция
- API: `generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent`
- Авторизация: API key (хранить в env `GOOGLE_AI_API_KEY`, не в коде)
- SDK: `github.com/google/generative-ai-go` (официальный Go SDK)
- Модель: `gemini-pro` или `gemini-1.5-pro`
```go
// google_gemini.go — реализует LLMProvider
type GeminiProvider struct {
client *genai.Client
model string
}
```
### Шаг 5: Конфигурация
Через env-переменные (не хардкод):
```bash
# Какой LLM-провайдер использовать
export AI_PROVIDER=google # google | cloud (в будущем)
# Google Gemini
export GOOGLE_AI_API_KEY=AIza... # API ключ
# Будущий облачный LLM
export CLOUD_LLM_ENDPOINT=https://llm.cloud.example.com/v1/analyze
export CLOUD_LLM_TOKEN=...
```
---
## 6. Миграция на облачный LLM
Когда облако предоставит свой LLM:
1. Создать `cloud_llm.go` — реализация `LLMProvider` для нового API
2. Переключить `AI_PROVIDER=cloud` в env
3. Код анализатора НЕ меняется — интерфейс `LLMProvider` тот же
4. Google Gemini остаётся как fallback (опционально)
```go
// Фабрика провайдеров — выбор по переменной окружения
func NewProvider(cfg Config) (LLMProvider, error) {
switch cfg.Provider {
case "google":
return NewGeminiProvider(cfg.GoogleAPIKey)
case "cloud":
return NewCloudLLMProvider(cfg.CloudEndpoint, cfg.CloudToken)
default:
return nil, fmt.Errorf("unknown AI provider: %s", cfg.Provider)
}
}
```
---
## 7. Безопасность
- API-ключи — только в env/secrets, никогда в коде или .tf файлах
- `terraform plan -json` может содержать sensitive data → фильтровать перед отправкой в LLM
- Ограничить размер payload (большие планы обрезать/суммаризировать)
- Логировать факт вызова AI, но НЕ содержимое запроса/ответа в production
---
## 8. Структура файлов (итого)
```
sless/
├── cmd/
│ └── sless-plan/ # CLI-обёртка для terraform plan + AI
│ └── main.go
├── internal/
│ └── ai/
│ ├── analyzer.go # логика анализа
│ ├── provider.go # интерфейс LLMProvider
│ ├── google_gemini.go # Google Gemini реализация
│ ├── cloud_llm.go # заглушка для облачного LLM
│ └── prompts.go # промпты по уровням 1–5
├── terraform/
│ └── provider/ # (существующий провайдер — без изменений)
└── examples/
└── */
└── variables.tf # добавить ai_hint_level variable
```
---
## 9. Порядок реализации
| # | Задача | Приоритет |
|----|------------------------------------------------|-----------|
| 1 | Создать `internal/ai/provider.go` (интерфейс) | HIGH |
| 2 | Реализовать `google_gemini.go` | HIGH |
| 3 | Написать промпты по уровням (`prompts.go`) | HIGH |
| 4 | Создать `analyzer.go` (оркестрация) | HIGH |
| 5 | CLI-обёртка `cmd/sless-plan/main.go` | HIGH |
| 6 | Добавить `ai_hint_level` в examples | MEDIUM |
| 7 | Тесты (unit + integration с mock LLM) | MEDIUM |
| 8 | Заглушка `cloud_llm.go` для будущей миграции | LOW |
| 9 | Документация для пользователей | MEDIUM |
---
## 10. Пример использования (целевой UX)
```bash
# В terraform проекте пользователя:
$ export TF_VAR_ai_hint_level=3
$ export GOOGLE_AI_API_KEY=AIza...
$ sless plan
# Terraform Plan output (стандартный)
# ...
# ──────────────── AI Analysis (level 3: MODERATE) ────────────────
# ⚠ Resource "kubernetes_deployment.func" зависит от "kubernetes_namespace.ns"
# но namespace создаётся в другом модуле — возможен race condition.
# ⚠ Порядок destroy может вызвать проблемы: trigger удалится раньше function.
# ✓ Синтаксис корректен, конфликтов не обнаружено.
# ─────────────────────────────────────────────────────────────────
```
---
## Решение зафиксировано
Подход: **интерфейс LLMProvider** → сначала Google Gemini → потом drop-in замена на облачный LLM.
Уровень задаётся через обычную Terraform variable. Никаких изменений в самом провайдере sless.
+159
View File
@@ -0,0 +1,159 @@
# API Design
Последнее обновление: 2026-03-18
## Базовый URL
```
http://<operator-host>:9090/v1
```
Локально: `http://localhost:9090/v1`
В кластере: `http://sless-operator.sless.svc.cluster.local:9090/v1`
Публично: `https://sless.kube5s.ru/v1/...` (через Ingress)
**Реализованные эндпоинты:**
```
GET /v1/namespaces/{ns}/functions
POST /v1/namespaces/{ns}/functions
GET /v1/namespaces/{ns}/functions/{name}
DELETE /v1/namespaces/{ns}/functions/{name}
POST /v1/namespaces/{ns}/functions/{name}/upload
GET /v1/namespaces/{ns}/triggers
POST /v1/namespaces/{ns}/triggers
GET /v1/namespaces/{ns}/triggers/{name}
DELETE /v1/namespaces/{ns}/triggers/{name}
GET /v1/namespaces/{ns}/functions/{name}/invocations
```
**Запланированные эндпоинты (ветка feat/web-console):**
```
GET /v1/namespaces/{ns}/functions/{name}/source ← НОВЫЙ: код из S3 zip
PATCH /v1/namespaces/{ns}/triggers/{name} ← НОВЫЙ: enable/disable триггера
```
**Глобальный сервис funcs (не оператор):**
```
GET https://sless.kube5s.ru/funcs/<namespace> ← без токена, plain text / HTML
GET https://sless.kube5s.ru/funcs?token=<jwt> ← с токеном
GET https://sless.kube5s.ru/health ← liveness probe
```
## Аутентификация
```
Authorization: Bearer <cloud-token>
```
Токен — JWT от `auth-api`. Middleware в операторе:
1. Извлекает `sub` из payload (без проверки подписи — доверяет Ingress)
2. Вычисляет namespace: `SHA256(sub)[:8]` hex → `sless-{16 hex символов}`
3. Проверяет что запрошенный `{namespace}` совпадает с вычисленным
## Ресурсы
### Functions
| Метод | Путь | Описание |
|-------|------|----------|
| GET | /functions | Список функций |
| POST | /functions | Создать функцию |
| GET | /functions/{id} | Получить функцию |
| PUT | /functions/{id} | Обновить функцию |
| DELETE | /functions/{id} | Удалить функцию |
### Versions (код функции)
| Метод | Путь | Описание |
|-------|------|----------|
| GET | /functions/{id}/versions | Список версий |
| POST | /functions/{id}/versions | Загрузить новый код (multipart zip) |
| GET | /functions/{id}/versions/{ver} | Получить версию |
| POST | /functions/{id}/versions/{ver}/activate | Активировать версию |
### Triggers
| Метод | Путь | Описание |
|-------|------|----------|
| GET | /functions/{id}/triggers | Список триггеров |
| POST | /functions/{id}/triggers | Создать триггер (HTTP/Cron) |
| DELETE | /functions/{id}/triggers/{tid} | Удалить триггер |
### Invocations (вызов и логи)
| Метод | Путь | Описание |
|-------|------|----------|
| POST | /functions/{id}/invoke | Синхронный вызов |
| GET | /functions/{id}/invocations | История вызовов |
| GET | /functions/{id}/invocations/{iid} | Детали вызова + логи |
## Upload endpoint
```
POST /v1/namespaces/{namespace}/functions/{name}/upload
Content-Type: multipart/form-data
Authorization: Bearer <token>
field: code = <zip-file>
```
Процесс:
1. Принимает zip (max 32MB)
2. Распаковывает zip
3. Генерирует `Dockerfile` (`FROM naeel/sless-runtime-{runtime}:latest\nCOPY . /app/function/`)
4. Перепаковывает в `tar.gz` (kaniko требует tar format)
5. Загружает в S3: `contexts/{ns}/{name}/{timestamp}.tar.gz`
6. Обновляет `fn.Spec.S3Key` → контроллер видит изменение и запускает kaniko Job
Ответ `200 OK`:
```json
{"message": "build queued", "phase": "Pending", "s3_key": "contexts/..."}
```
## Поддерживаемые runtime (v1)
- `python3.11` — реализован и протестирован
- `go1.21` — планируется
- `nodejs20` — планируется
## Модель Function
```json
{
"id": "fn-uuid",
"name": "my-function",
"description": "...",
"runtime": "python3.11",
"entrypoint": "handler.handle",
"memory_mb": 128,
"timeout_sec": 30,
"env_vars": {"KEY": "value"},
"active_version": "1",
"status": "active",
"created_at": "...",
"updated_at": "..."
}
```
## Модель Trigger
```json
{
"id": "tr-uuid",
"type": "http",
"url": "https://sless.api.ngcloud.ru/invoke/fn-uuid",
"created_at": "..."
}
```
```json
{
"id": "tr-uuid",
"type": "cron",
"schedule": "0 * * * *",
"created_at": "..."
}
```
+184
View File
@@ -2,6 +2,190 @@
> Сюда записываем проблемы с которыми столкнулись и как их решили.
---
## 2026-03-17 — Баг 3: PodLogOptions compile error (v0.1.31)
**Проблема:** Оператор не компилировался. Ошибка:
```
controllers/functionjob_controller.go: unknown field Stdout in corev1.PodLogOptions
controllers/functionjob_controller.go: unknown field Stderr in corev1.PodLogOptions
```
**Причина:** `corev1.PodLogOptions{}` в k8s API не имеет полей `Stdout` и `Stderr` — это поля из `corev1.ContainerState`. Код выглядел так:
```go
req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{
Stdout: true, // не существует
Stderr: true, // не существует
})
```
**Решение:** Убрать несуществующие поля. `GetLogs` по умолчанию возвращает stdout+stderr без дополнительных флагов:
```go
req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{})
```
**Файл:** `controllers/functionjob_controller.go`, функция `getJobPodOutput`
**Версия:** исправлено в `naeel/sless-operator:v0.1.31`
---
## 2026-03-17 — Главная причина провала POSTGRES example: неправильный nubes_endpoint
### Симптом
`terraform apply` для `examples/POSTGRES` падал с `"job failed, check pod logs"`. При этом `examples/simple-python` и `examples/hello-node` работали нормально на тест-стенде.
### Корневая причина
В `examples/POSTGRES/main.tf` в блоке `provider "sless"` был указан **продовый** endpoint Nubes API:
```hcl
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
token = var.api_token
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1" # ← ПРОД
}
```
При этом `provider "nubes"` уже указывал на тест:
```hcl
provider "nubes" {
api_token = var.api_token
api_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm" # ← тест ✓
}
```
Из-за этого `sless_function` и `sless_job` стучались в **продовый** Nubes API, где у тестового пользователя другой namespace и другой кластер. Postgres и pod запускались в контексте прода, а не тест-стенда. Поды не могли подключиться к базе.
### Фикс
```hcl
# examples/POSTGRES/main.tf
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
token = var.api_token
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1" # ← тест
}
```
Аналогично исправлено в `examples/simple-python/main.tf`.
### Почему долго искали
Долгое расследование ушло на "split-brain" — API-созданные FunctionJob'ы были невидимы через `kubectl get functionjobs -A`. Это объяснялось тем что:
1. Job завершался (Failed) за ~5 секунд
2. Между завершением и опросом kubectl объект уже мог быть очищен
3. Параллельно в кластере фигурировал другой namespace (`sless-ffd1f598c169b0ae` — прод) vs тест-namespace
На самом деле контроллер работал корректно. Проблема была исключительно в `nubes_endpoint`.
### Урок
**Всегда проверять**: когда в `main.tf` два провайдера (`nubes` + `sless`) — у обоих endpoint'ы должны указывать на **одну среду**. Смешивание прод/тест endpoint'ов в одном apply даёт непредсказуемые результаты.
---
## 2026-03-17 — Ошибка агента: локальный запуск terraform
**Проблема:** Агент запускал `terraform apply` **локально** (`/home/naeel/remote_dev/sless/examples/...`) вместо запуска на удалённом сервере `naeel@5.172.178.213`.
**Почему неправильно:**
- Провайдер `terra.k8c.ru/naeel/sless` установлен только на удалённом сервере
- Локальный terraform не имеет доступа к k8s кластеру напрямую
- Потенциально затрагивал продовые API из локальной сети
**Правило:** Все `terraform init/plan/apply/destroy` для `examples/` — только через SSH на `naeel@5.172.178.213`:
```bash
ssh -i /home/naeel/remote_dev/common/id_ed25519.txt naeel@5.172.178.213 \
'cd /home/naeel/terra/sless/examples/<example> && terraform apply -auto-approve -no-color'
```
---
## 2026-03-17 — FunctionJob всегда "job failed, check pod logs" — расследование и два бага
### Хронология расследования
**Симптом:** `terraform apply` на `examples/POSTGRES` завершается ошибкой:
```
job failed: job failed, check pod logs: kubectl logs -n sless-fn-sless-ffd1f598c169b0ae -l functionjob=pg-create-table-job-main-v12
```
Команда из сообщения — ничего не выдаёт (под не найден).
---
**Ошибка агента №1 — SQL-диагностика вместо анализа кода**
Первая реакция — запустить диагностические поды в кластере с psycopg2 чтобы проверить подключение к PostgreSQL. Это было **неправильно**: проблема не в подключении, а в том что job не запускался вообще. Пользователь остановил — "ПРИ ЧЁМ тут SQL запросы???".
---
**Ошибка агента №2 — не читал собственный код**
Раньше читались логи оператора и kubectl describe — но не исходный код контроллеров. Пользователь указал: "мы пишем ВСЁ сами, смотри в код". После прочтения `functionjob_controller.go` и `functions.go` картина сложилась за одно чтение.
---
### Баг 1 — k8s label `job-name` удалён в 1.27+
**Файл:** `controllers/functionjob_controller.go`, функция `getJobPodOutput`
**Код до:**
```go
pods, err := kube.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
LabelSelector: "job-name=" + jobName,
})
if err != nil || len(pods.Items) == 0 {
return "completed successfully" // ← сюда всегда попадали
}
```
**Причина:** В k8s 1.27 лейбл `job-name` на pod-ах был deprecated, в 1.32+ удалён полностью. У нас кластер **1.34.1**. Поэтому `List` возвращал 0 подов всегда → fallback `"completed successfully"` → при `phase=Failed` финальное сообщение `"job failed, check pod logs: ..."`.
Логи реального сбоя никогда не попадали в `status.Message` FunctionJob, поэтому **корневая ошибка Python-функции была невидима**.
**Как нашли:** Проверили `kubectl get pod -l "batch.kubernetes.io/job-name"` — вернул поды. `kubectl get pod -l "job-name"` — не вернул. Версия кластера: `v1.34.1` (kubectl `v1.35.2`).
**Фикс:** Использовать собственный лейбл `functionjob=<fj.Name>`, который мы сами выставляем на PodTemplate и который работает независимо от версии k8s.
---
**Также:** В строке 211 подсказка для пользователя тоже использовала устаревший лейбл:
```go
"job failed, check pod logs: kubectl logs -n " + job.Namespace + " -l job-name=" + job.Name
```
Исправлено на `functionjob=<fj.Name>` — теперь команда реально работает.
---
### Баг 2 — Split-brain cached client при CreateFunction
**Файл:** `internal/api/handler/functions.go`, функция `CreateFunction`
**Проявление:** API возвращает `409 "function already exists"`, а `kubectl get function -A` функцию не видит. `terraform state rm sless_function.*` не помогает — следующий `terraform apply` снова получает 409.
**Причина:** `h.K8s` в handler — это **cached client** controller-runtime. Цепочка:
1. Предыдущий `terraform destroy` вызвал `DELETE /functions/pg-create-table-runner`
2. Handler вызвал `h.K8s.Delete(ctx, fn)` — объект удалён из **etcd**
3. Кеш controller-runtime обновляется асинхронно (informer watch). Несколько секунд объект ещё жив в памяти оператора
4. Следующий `terraform apply` → `POST /functions` → `h.K8s.Create(ctx, fn)` → `IsAlreadyExists` (кеш ещё видит объект)
5. Код проверяет `phase == Failed` — но объект в кеше в фазе `Ready` → уходит в 409 навсегда
`kubectl get function` шёл **мимо кеша** (прямо в k8s API) → NotFound. API handler шёл **через кеш** → AlreadyExists. Поэтому они показывали разные результаты.
**Фикс:** При `IsAlreadyExists` делать `uncached Get` (через `client.ObjectKey` напрямую). Если объект реально не найден в etcd (`IsNotFound`) — значит кеш устарел, смело создаём заново. Если найден — возвращаем 409 как обычно (объект реально существует).
---
### Итог
| # | Баг | Файл | Строка | Тип |
|---|-----|------|--------|-----|
| 1 | `LabelSelector: "job-name="` deprecated k8s 1.27+ | `controllers/functionjob_controller.go` | 311 | Совместимость |
| 1b | Подсказка `kubectl logs -l job-name=` тоже устарела | `controllers/functionjob_controller.go` | 211 | UX |
| 2 | Cached client → perpetual 409 при CreateFunction | `internal/api/handler/functions.go` | 117-133 | Race condition |
## Шаблон записи
```
-89
View File
@@ -1,89 +0,0 @@
# Работа с удалённой машиной Nubes
Дата: 2026-03-12
## Проблема
Локальный терминал Copilot нестабилен из-за VPN — команды зависают, таймауты.
**Весь код физически живёт на удалённой Ubuntu VM**, доступной по SSH.
## Архитектура
```
Локальная машина (Krupski)
└── SSHFS → /home/naeel/remote_dev → naeel@5.172.178.213:/home/naeel/terra
└── sless/ ← реальный репозиторий
```
## Подключение к VM
```bash
# Хост
VM_HOST=naeel@5.172.178.213
VM_KEY=/home/naeel/.ssh/id_ed25519 # ~/.ssh/id_ed25519 = копия secrets/naeel_vm_id_ed25519
# Проверить подключение
ssh -i /home/naeel/.ssh/id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 "hostname && whoami"
# Выполнить команду удалённо
ssh -i /home/naeel/.ssh/id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 "cd /home/naeel/terra/sless && <команда>"
```
## Путь к репозиторию на VM
```
/home/naeel/terra/sless
```
## Git push через SSH (Gitea)
Remote origin использует HTTPS → нужен SSH для беспарольного пуша.
На VM уже лежит ключ `~/.ssh/gitea_key` (тот же ключ, что `id_ed25519`, зарегистрирован в Gitea).
```bash
# Пуш с VM
ssh -i /home/naeel/.ssh/id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 \
'cd /home/naeel/terra/sless && \
GIT_SSH_COMMAND="ssh -i ~/.ssh/gitea_key -o StrictHostKeyChecking=no" \
git push git@gitea-naeel.giteak8s.services.ngcloud.ru:naeel/sless.git <branch>'
```
## Gitea SSH ключи
Ключ `~/.ssh/id_ed25519` зарегистрирован в Gitea:
- Email: tazet@narod.ru
- Fingerprint: `SHA256:WUAvQXXmRUr6jHHdkMDljKKBUEQ8ot2cTU3YSixqYe4`
## Типовой git workflow (commit + push)
Токен лежит в `secrets/gitea.token` (Gitea PAT, repository read+write).
```bash
TOKEN=$(cat /home/naeel/remote_dev/sless/secrets/gitea.token | tr -d '\n')
ssh -i /home/naeel/.ssh/id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 "
cd /home/naeel/terra/sless
git add <файлы>
git commit -m '<сообщение>'
git push https://naeel:${TOKEN}@gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless.git <branch>
"
```
## SSHFS монтирование (восстановить если упало)
```bash
sshfs \
-o IdentityFile=/home/naeel/.ssh/id_ed25519 \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o reconnect \
-o ServerAliveInterval=15 \
-o ServerAliveCountMax=3 \
naeel@5.172.178.213:/home/naeel/terra /home/naeel/remote_dev
```
## Примечания
- `secrets/naeel_vm_id_ed25519` и `~/.ssh/id_ed25519` — одинаковые ключи (SHA256 совпадает)
- При зависании локального терминала — выполнять команды через SSH на VM напрямую
- Git config на VM настроен: `user.name=Naeel`, `user.email=naeel@nubes.ru`
+204
View File
@@ -0,0 +1,204 @@
# Архитектура системы
Последнее обновление: 2026-03-18 (v0.1.33 + funcs-service v0.1.3)
## Общее описание
Managed Serverless Functions Service для облачного провайдера nubes.ru.
Пользователь загружает код через Terraform, сервис его собирает (kaniko) и запускает
по HTTP-триггеру, расписанию (cron) или вручную через one-shot Job.
## Стек
| Компонент | Технология | Где запущен |
|-----------|-----------|-------------|
| Operator (API + Controllers) | Go (controller-runtime) | Kubernetes, namespace `sless` |
| funcs-service (глобальная консоль) | Go (net/http) | Kubernetes, namespace `sless` |
| PostgreSQL | PostgreSQL 16 | Kubernetes, namespace `sless` |
| S3 | Ceph (облачный) | `s3.msk-1.ngcloud.ru` |
| Container Registry | DockerHub (`naeel/`) | внешний |
| Builder | kaniko (k8s Job) | namespace пользователя |
| Функции (HTTP) | k8s Deployment + Service | namespace пользователя |
| Функции (one-shot) | k8s Job | namespace пользователя |
| Функции (cron) | k8s CronJob | namespace пользователя |
| Terraform Provider | Go (plugin framework v6) | localhost/CI |
| nubes API | REST (облако) | `deck-api.ngcloud.ru` |
> Redis и RabbitMQ — отложены до v2.
## Компонент: funcs-service
Глобальный HTTP сервис — **одна копия** на весь кластер, для всех пользователей.
```
User Browser / curl
└─► https://sless.kube5s.ru/funcs/<namespace>
└─► nginx Ingress (sless-funcs-ingress)
└─► sless-funcs-service:8090 (namespace sless)
└─► http://sless-operator.sless.svc.cluster.local:9090/v1/...
```
**Файлы:**
- `services/funcs/main.go` — логика
- `services/funcs/Dockerfile` — multi-stage Go → alpine
- `deployments/k8s/funcs-service.yaml` — Deployment + Service + Ingress
**Env vars сервиса:**
| Переменная | Значение |
|-----------|---------|
| `SLESS_OPERATOR_URL` | `http://sless-operator.sless.svc.cluster.local:9090` |
| `SLESS_EXTERNAL_URL` | `https://sless.kube5s.ru` |
| `SLESS_EXCLUDE` | `event-writer,event-monitor,event-cleaner` |
| `SLESS_SERVICE_TOKEN` | JWT токен (задаётся через `kubectl set env`, не в git) |
## Хранение кода функций в S3
```
Terraform source_dir (локально)
└─► zip → POST /upload → builder.PrepareContext()
├─► functions/{ns}/{name}/{ts}.zip ← ИСХОДНЫЙ КОД пользователя
└─► contexts/{ns}/{name}/{ts}.tar.gz ← BUILD CONTEXT для kaniko
└─► Function CRD: spec.s3Key = "contexts/..."
└─► контроллер → kaniko Job → Docker image
```
Для web-консоли: `GET /source` читает `functions/{ns}/{name}/{ts}.zip` напрямую.
## Изоляция пользователей — Namespace per user
Каждый пользователь облака получает **отдельный k8s namespace**.
```
JWT токен (Bearer)
└─► JWT.sub (строка "0199e325-1cdf-7cda-9319-e5302a85e291")
└─► SHA256(sub) → первые 8 байт → hex → "sless-{16 hex символов}"
└─► namespace = "sless-cdd874dfa31ba6ca"
```
- Namespace детерминирован: один sub → всегда один namespace.
- sub не раскрывается в имени namespace (SHA256 необратим).
- Длина 22 символа — укладывается в лимит k8s (63).
**Кто создаёт namespace:**
Terraform провайдер при Configure() вызывает POST /v1/namespaces/{ns}/ensure
**один раз**, до любых ресурсных операций.
Resource-хендлеры (Function, Trigger, Job) namespace **не создают** — это не их ответственность.
## Аутентификация
### Оператор (REST API)
- Bearer JWT в заголовке Authorization
- Проверяется структура JWT (3 части), наличие sub claim, срок действия exp
- Подпись **не проверяется** — trusted perimeter (оператор за Ingress)
### Terraform Provider (при Configure)
1. Декодирует JWT → sub
2. Вычисляет namespace через SHA256
3. Если задан nubes_endpoint — пингует nubes API (GET <nubes_endpoint>) с тем же токеном
- HTTP 401/403 → ошибка инициализации провайдера
- Недоступен → ошибка инициализации
4. Вызывает POST /v1/namespaces/{ns}/ensure (создаёт namespace если нет)
## Схема вызова
```
Пользователь (curl / браузер)
|
v GET|POST|... /fn/{namespace}/{name}/*
sless-api Ingress -> Operator /fn/ прокси
|
v HTTP forward -> http://{name}.{namespace}.svc.cluster.local:8080
k8s Service -> Deployment/Pod функции
```
```
terraform apply
|
v provider Configure()
1. JWT -> sub -> namespace
2. PingNubesAPI (валидация токена)
3. POST /v1/namespaces/{ns}/ensure <- создаёт k8s namespace
|
+-> POST /v1/namespaces/{ns}/functions <- создаёт Function CRD
| +-> POST /upload (zip) <- загружает код -> S3 -> kaniko Job
| +-> polling phase=Ready
|
+-> POST /v1/namespaces/{ns}/triggers <- создаёт Trigger CRD
| +-> controller: Deployment + Service + (CronJob для cron)
|
+-> POST /v1/namespaces/{ns}/jobs <- создаёт FunctionJob CRD
+-> controller: k8s Job -> result в status
```
## Структура кода
```
sless/
|-- main.go точка входа: k8s manager + REST API сервер (goroutine)
|-- internal/
| |-- api/
| | |-- router.go gorilla/mux: /fn/ (публичный), /v1/ (auth + middleware)
| | |-- handler/
| | | |-- handler.go Handler struct + helpers (writeJSON, namespace(), pathVar())
| | | |-- namespace.go EnsureNamespace (POST /v1/namespaces/{ns}/ensure)
| | | |-- functions.go CRUD Function
| | | |-- triggers.go CRUD Trigger
| | | |-- jobs.go CRUD FunctionJob
| | | |-- upload.go zip -> Dockerfile -> tar.gz -> S3 -> CRD patch
| | | |-- invoke.go прокси /fn/{ns}/{name} -> in-cluster DNS
| | | +-- invocations.go 501 stub (реализация отложена)
| | +-- middleware/
| | |-- auth.go JWT validation (struct + sub + exp, подпись не проверяется)
| | +-- logging.go slog request logger
| |-- builder/
| | |-- builder.go kaniko Job lifecycle (Build, JobStatus, Cleanup)
| | +-- context.go PrepareContext: zip+runtime → tar.gz+Dockerfile для kaniko
| |-- config/config.go Load() из env vars
| +-- storage/
| |-- postgres/store.go SaveInvocation, ListInvocations, RunMigrations
| +-- s3/client.go Upload, Download, UploadContext (tar.gz для kaniko)
|-- controllers/
| |-- function_controller.go Reconcile: Pending->Building->Ready/Failed + Deployment
| |-- trigger_controller.go Reconcile: Service+Ingress (http) / CronJob (cron)
| +-- functionjob_controller.go Reconcile: k8s Job -> Succeeded/Failed + output capture
|-- api/v1alpha1/
| |-- function_types.go Function CRD
| |-- trigger_types.go Trigger CRD
| +-- job_types.go FunctionJob CRD
|-- deployments/k8s/
| |-- operator.yaml Deployment + Service + Ingress
| +-- rbac.yaml ClusterRole + ClusterRoleBinding + ServiceAccount
|-- terraform/provider/ независимый Go-модуль
| +-- internal/
| |-- client/client.go SubFromJWT, NamespaceFromSub, PingNubesAPI + CRUD
| |-- provider/provider.go Configure(): JWT->NS->ping->EnsureNamespace
| +-- resources/
| |-- function_resource.go sless_function
| |-- trigger_resource.go sless_trigger
| +-- job_resource.go sless_job
+-- runtimes/
|-- python3.11/ server.py + Dockerfile -> naeel/sless-runtime-python3.11:v0.1.1
+-- nodejs20/ server.js + Dockerfile -> naeel/sless-runtime-nodejs20:v0.1.2
```
## Kubernetes кластер
Сейчас используется существующий кластер (временный).
Планируется переезд на новый кластер — манифесты переносятся без изменений.
Ноды:
- wheel-control-plane-fm9sr — control-plane
- wheel-workers-tv4qr-r45xs — worker
- wheel-workers-tv4qr-x8xw7 — worker
Ingress: nginx, external IP 5.172.178.182
API endpoint: https://sless-api.kube5s.ru
## Версии в production
| Артефакт | Тег/Версия |
|---------|-----------|
| naeel/sless-operator | v0.1.22 |
| terra.k8c.ru/naeel/sless провайдер | v0.1.13 |
| naeel/sless-runtime-python3.11 | v0.1.1 |
| naeel/sless-runtime-nodejs20 | v0.1.2 |
+203 -32
View File
@@ -1,6 +1,208 @@
# Прогресс разработки
Последнее обновление: 2026-03-10 (добавлен прагматичный обзор Claude Opus 4.6)
Последнее обновление: 2026-03-19 09:00
## 2026-03-19 — pg-table-writer HTML + bugfix invoke.go Content-Length (оператор v0.1.37)
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Python runtime v0.1.4: `text/html` поддержка | ✅ | `server.py`: str начинается с `<` → `text/html; charset=utf-8`; добавлен `_accept` в event |
| 2 | `internal/builder/context.go`: python runtime → v0.1.4 | ✅ | |
| 3 | Образ `naeel/sless-runtime-python3.11:v0.1.4` | ✅ | Собран и запушен |
| 4 | `code/table-rw/table_rw.py` | ✅ | Комбинированный reader+writer. `list_rows` — JSON. `add_row`: GET → HTML форма, POST form → INSERT + HTML ответ. `_render_page` шаблон с тёмной темой |
| 5 | `examples/POSTGRES/resources.tf` | ✅ | reader: `source_dir=code/table-rw`, `entrypoint=table_rw.list_rows`. Новые: `sless_function.postgres_table_writer`, `sless_trigger.postgres_table_writer_http`, `output.table_writer_url` |
| 6 | Удалена папка `code/table-reader/` | ✅ | |
| 7 | `internal/builder/builder.go`: убран `--cache=true` | ✅ | kaniko по умолчанию не кэширует слои |
| 8 | Оператор v0.1.35 | ✅ (промежуточный) | |
| 9 | Оператор v0.1.36 | ✅ | Убран ошибочный `--no-cache` (kaniko v1.24.0 не поддерживает этот флаг) |
| 10 | Оператор v0.1.37 | ✅ | **Bugfix `invoke.go`**: добавлен `proxyReq.ContentLength = r.ContentLength`. Без этого Python-сервер читал тело как 0 байт (бесконечно chunked) — форма не парсилась |
| 11 | POST через прокси | ✅ | `curl POST title=test-proxy-fix` → HTML с «Добавлено: «test-proxy-fix»», строка в таблице |
### Root cause бага формы
`internal/api/handler/invoke.go`: при проксировании POST-запроса не устанавливался `Content-Length`.
Go `http.Client` отправлял запрос без `Content-Length` → Python `BaseHTTPRequestHandler.headers.get("Content-Length", 0)` = 0 → тело не читалось → `title` пустой → JSON ошибка.
Фикс: одна строка `proxyReq.ContentLength = r.ContentLength`.
### Образы
| Образ | Версия | Что изменилось |
|-------|--------|----------------|
| `naeel/sless-operator` | v0.1.37 | `invoke.go`: `proxyReq.ContentLength = r.ContentLength` |
| `naeel/sless-runtime-python3.11` | v0.1.4 | `text/html` support, `_accept` in event |
---
## 2026-03-18 — web-консоль (оператор v0.1.34 + funcs-service v0.2.0)
## 2026-03-18 — web-консоль (оператор v0.1.34 + funcs-service v0.2.0)
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Оператор: `GET /v1/.../functions/{name}/source` | ✅ | `internal/api/handler/source.go` — скачивает tar.gz контекст из S3; извлекает пользовательские файлы (без Dockerfile); возвращает `[{name, content, binary}]` |
| 2 | Оператор: `PATCH /v1/.../triggers/{name}` | ✅ | Уже существовал в v0.1.33 — Handler обрабатывает поле `enabled` |
| 3 | `internal/api/router.go` — маршрут /source | ✅ | `GET /namespaces/{ns}/functions/{name}/source → h.GetSource` |
| 4 | `services/funcs/index.html` — HTML web-консоль | ✅ | Тёмная тема, аккордеоны, highlight.js, кнопки ▶/■ для enable/disable триггеров |
| 5 | `services/funcs/main.go` — HTML режим + proxy endpoints | ✅ | `Accept: text/html` → HTML; `GET /funcs/{ns}/source/{fn}` и `PATCH /funcs/{ns}/triggers/{name}` — прокси к оператору через SLESS_SERVICE_TOKEN |
| 6 | Backward compat: plain text | ✅ | curl без `Accept: text/html` — старое поведение v0.1.x |
| 7 | Оператор v0.1.34 | ✅ | Build+push+deploy в кластер |
| 8 | funcs-service v0.2.0 | ✅ | Build+push+deploy в кластер |
| 9 | Коммит | ✅ | `bf9f073` — `feat: web-console — HTML UI + source viewer + trigger toggle` |
### Текущие URL
```
https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae # браузер → HTML консоль
# curl → plain text (backward compat)
https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae/source/pg-info # JSON файлы функции
https://sless.kube5s.ru/fn/sless-ffd1f598c169b0ae/pg-info # вызов функции
```
### Образы
| Образ | Версия | Что изменилось |
|-------|--------|----------------|
| `naeel/sless-operator` | v0.1.34 | GET /source endpoint, HTML web-консоль proxy routes |
| `naeel/sless-funcs-service` | v0.2.0 | HTML консоль + source/trigger proxy endpoints |
---
## 2026-03-18 — FunctionJob bugfix (functionjob label + stderr capture)
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | `functionjob_controller.go`: label на PodTemplate | ✅ | Добавлен `PodTemplate.ObjectMeta.Labels: {managed-by, functionjob, function}` — k8s 1.27+ удалил встроенный `job-name=` лейбл |
| 2 | `getJobPodOutput` по `labelSelector` | ✅ | Принимает `"functionjob=<name>"` вместо `jobName`; используется для успешного и упавшего job |
| 3 | Захват stderr при ошибке Job | ✅ | При `job.Status.Failed > 0` — берём `podOutput`; если непустой — пишем в `status.Message` (truncate до 2000 символов) |
| 4 | `truncateForStatus()` helper | ✅ | Ограничивает длину `status.Message` для безопасной записи в k8s |
| 5 | terraform client: `ErrJobAlreadyExists` | ✅ | `client.go`: 409 Conflict → `ErrJobAlreadyExists`; `job_resource.go`: при конфликте создания — читаем существующий job вместо ошибки |
---
## 2026-03-18 — Python runtime str→text/plain
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | `runtimes/python3.11/server.py` | ✅ | Если функция вернула `str` → `Content-Type: text/plain`, тело без `json.dumps`. Dict/list остаются `application/json` |
| 2 | `internal/builder/context.go` | ✅ | Python runtime базовый образ → `v0.1.3` |
| 3 | Образ `naeel/sless-runtime-python3.11:v0.1.3` | ✅ | Собран и задеплоен |
---
## 2026-03-18 — funcs: глобальный Go сервис + ветка web-console
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Python runtime str→text/plain | ✅ | `runtimes/python3.11/server.py`: если функция возвращает `str` → `Content-Type: text/plain`. Образ `naeel/sless-runtime-python3.11:v0.1.3` |
| 2 | funcs_list.py: plain text вывод | ✅ | Переписан с JSON на plain text с `─` разделителями, фильтр `SLESS_EXCLUDE` |
| 3 | Оператор v0.1.33 | ✅ | context.go: python runtime → v0.1.3; `internal/builder/context.go` обновлён |
| 4 | `funcs` как глобальный Go сервис | ✅ | `services/funcs/main.go` — standalone Go HTTP сервер. Деплоится ОДИН РАЗ в namespace `sless`, работает для ВСЕХ пользователей. Образ `naeel/sless-funcs-service:v0.1.3` |
| 5 | URL без токена | ✅ | `https://sless.kube5s.ru/funcs/<namespace>` — открывается в браузере без токена. `SLESS_SERVICE_TOKEN` задан через `kubectl set env` |
| 6 | Удалить funcs из terraform | ✅ | `examples/POSTGRES/resources.tf`: удалены `sless_function.funcs_list`, `sless_trigger.funcs_list_http`, `output "funcs_url"`, `local.user_namespace`. `terraform apply` — 2 destroyed |
| 7 | Ветка feat/web-console | ✅ | Создана от `feat/harbor-integration` на remote, запушена |
| 8 | Документация архитектуры хранения кода | ✅ | См. `doc/decisions/log.md` раздел "Хранение кода функций и web-консоль" |
| 9 | Коммиты | ✅ | `e8cd62e` (funcs Go service), `38bb494` (v0.1.3 — /funcs/namespace без токена) |
### Текущие URL функций
```
https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae # список функций (без токена)
https://sless.kube5s.ru/funcs?token=<jwt> # то же, через токен
https://sless.kube5s.ru/fn/sless-ffd1f598c169b0ae/pg-info
https://sless.kube5s.ru/fn/sless-ffd1f598c169b0ae/pg-table-reader
```
### Образы
| Образ | Версия | Что изменилось |
|-------|--------|----------------|
| `naeel/sless-operator` | v0.1.33 | created_at, last_built_at в API; python runtime v0.1.3 |
| `naeel/sless-runtime-python3.11` | v0.1.3 | str → text/plain в server.py |
| `naeel/sless-funcs-service` | v0.1.3 | /funcs/<namespace> без токена, /health probe |
---
## 2026-03-18 — Следующие шаги: web-консоль (ветка feat/web-console)
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Оператор: `GET /v1/namespaces/{ns}/functions/{name}/source` | ✅ | Реализовано — `bf9f073` |
| 2 | Оператор: `PATCH /v1/namespaces/{ns}/triggers/{name}` | ✅ | Существовал — проверено, поддерживает `enabled` |
| 3 | `sless-funcs-service`: HTML страница | ✅ | `Accept: text/html` → HTML консоль. Аккордеоны + highlight.js + кнопки ▶/■ |
| 4 | Bump оператора до v0.1.34 | ✅ | Задеплоен |
| 5 | Bump funcs-service до v0.2.0 | ✅ | Задеплоен |
---
## 2026-03-18 12:00 — DNS инцидент устранён, POSTGRES E2E PASS
## 2026-03-18 — DNS инцидент: sless-api.kube5s.ru → мёртвый кластер
| # | Задача | Статус | Заметки |
|---|--------|--------|----------|
| 1 | Диагностика TLS handshake timeout при `terraform apply` POSTGRES | ✅ | `sless-api.kube5s.ru` → `5.172.178.182` (старый кластер). Порт 443 принимал TCP, но TLS не завершался — Go HTTP клиент висел бесконечно |
| 2 | Подтверждение нового кластера | ✅ | `kubectl get nodes` в контексте `tazetdinovn@gmail.com@naeel-test-3` → 3 ноды Ready. Ingress IP `185.247.187.147`. DNS `sless.kube5s.ru` уже указывал на него |
| 3 | Обновить Ingress в кластере | ✅ | `kubectl patch ingress sless-operator -n sless`: host + TLS → `sless.kube5s.ru`, secretName → `sless-operator-tls-sless` |
| 4 | Обновить ConfigMap оператора | ✅ | `EXTERNAL_URL: https://sless.kube5s.ru` (было `https://sless-api.kube5s.ru`) |
| 5 | Let's Encrypt сертификат | ✅ | `certificate/sless-operator-tls-sless` READY=True, order valid — выдан сразу после смены ingress host (ACME HTTP-01 challenge на новом IP прошёл) |
| 6 | Заменить `sless-api.kube5s.ru` → `sless.kube5s.ru` во всех examples + deployments | ✅ | `POSTGRES/`, `hello-go/`, `hello-node/`, `simple-node/`, `simple-python/`, `notes-python/`, `pg-list-python/`, `demo-managed-functions/`, `README.md`, `deployments/k8s/operator.yaml` |
| 7 | POSTGRES `terraform apply` с функцией и job | ✅ PASS | `sless_function` `pg-create-table-runner` — создан за 55s. `sless_job` `pg-create-table-job-main-v13` — создан за 20s. `Apply complete! Resources: 2 added` |
| 8 | Коммит и push | ✅ | `cca3a8c` — `fix: migrate sless endpoint from sless-api.kube5s.ru to sless.kube5s.ru` |
---
## 2026-03-17 — E2E тесты всех примеров на тест-стенде
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Исправить `nubes_endpoint` во всех examples (prod→test) | ✅ | `deck-api.ngcloud.ru` → `deck-api-test.ngcloud.ru/api/v1` в hello-node, hello-go, simple-node, notes-python, pg-list-python, demo-managed-functions |
| 2 | Исправить `sless_endpoint` в demo-managed-functions | ✅ | `185.247.187.147.nip.io` → `https://sless-api.kube5s.ru` |
| 3 | Postgres для тестов | ✅ | `postgres.sless.svc.cluster.local:5432`, user=`sless`, pass=`sless-pg-password`, db=`sless`. Уже запущен в кластере |
| 4 | `hello-node` E2E | ✅ PASS | init → apply (4 ресурса) → modify (memory_mb 128→256, env_var) → apply → destroy |
| 5 | `simple-python` E2E | ✅ PASS | init → apply → modify (memory_mb 64→96) → apply → destroy. `job_result = {"time": "..."}` |
| 6 | `simple-node` E2E | ✅ PASS | Аналогично simple-python но nodejs20 |
| 7 | `notes-python` E2E | ✅ PASS | 7 ресурсов, PostgreSQL init job: `{"ok": true, "executed": 1}`, destroy complete |
| 8 | Коммит на remote | ✅ | `a768454` в `/home/naeel/terra/sless/examples` |
| 9 | POSTGRES example | 🔄 | Следующий шаг — вернуться к POSTGRES после прохождения всех тестов |
---
## 2026-03-17 — v0.1.31: compile fix + POSTGRES example end-to-end
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Баг 3: `PodLogOptions{Stdout/Stderr}` compile error | ✅ | Убраны несуществующие поля. `getJobPodOutput` в `functionjob_controller.go` |
| 2 | Сборка `naeel/sless-operator:v0.1.31` | ✅ | Собрано на `naeel@5.172.178.213`, запушено в Docker Hub |
| 3 | Деплой v0.1.31 в кластер | ✅ | `kubectl rollout status` → success. Pod `sless-operator-545556c59d-n8qv9` |
| 4 | `simple-python` — сквозной тест на тест-стенде | ✅ | `terraform apply` на remote: `Apply complete! Resources: 3 added`. `job_result = {"time": "..."}` |
| 5 | Найдена главная причина падения POSTGRES | ✅ | `nubes_endpoint` в `provider "sless"` указывал на **прод** `deck-api.ngcloud.ru` |
| 6 | Исправлен `nubes_endpoint` в POSTGRES/main.tf | ✅ | Теперь `deck-api-test.ngcloud.ru/api/v1` |
| 7 | Исправлен `nubes_endpoint` в simple-python/main.tf | ✅ | Аналогично |
| 8 | End-to-end `terraform apply` для POSTGRES | 🔄 | Следующий шаг: синхронизировать на remote и запустить `terraform apply` |
---
## 2026-03-17 — POSTGRES example: внешний managed PG, FunctionJob и реальные блокеры
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Перевод `examples/POSTGRES` на `PGSSLMODE=require` | ✅ | Для managed PostgreSQL `allow_no_s_s_l = false`; `disable` был некорректен |
| 2 | Подтверждение root-cause `password authentication failed` | ✅ | Из runtime namespace поднят диагностический pod того же image: `psycopg2.OperationalError: FATAL: password authentication failed for user "u-user0"` |
| 3 | Проверка источника пароля | ✅ | `nubes_postgres.npg.vault_secrets["users"]` не совпадает с фактическим паролем пользователя в кластере managed PostgreSQL; authoritative source сейчас — k8s secret `u-user0.postgresqlk8s.credentials.postgresql.acid.zalan.do` |
| 4 | Попытка читать пароль через `hashicorp/external` | ❌ | В текущем окружении доступен только кастомный registry; `registry.terraform.io/hashicorp/external` не устанавливается |
| 5 | One-command `terraform apply` для `examples/POSTGRES` | 🔄 | Остаточный баг локализован в связке credential-source + FunctionJob observability. После переключения на актуальный пароль `sless_job` всё ещё падает, но актуальные pod logs реального job теряются из-за TTL/слабой наблюдаемости |
| 6 | Необходимый следующий технический шаг | 🔄 | Либо добавить в проект доступный data-source/провайдер для чтения k8s secret внутри apply, либо починить источник пароля в nubes-потоке, чтобы `vault_secrets` и реальный Postgres user password совпадали |
## 2026-03-17 — Fix: `examples/POSTGRES` / `sless_job` 409 + диагностика FunctionJob
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Идемпотентность `sless_job` при `409 job already exists` | ✅ | `terraform/provider/internal/client/client.go`, `terraform/provider/internal/resources/job_resource.go`: добавлен typed-error `ErrJobAlreadyExists`, в Create при 409 читается существующий Job и продолжается ожидание/синхронизация вместо немедленного падения |
| 2 | Диагностика падений FunctionJob | ✅ | `controllers/functionjob_controller.go`: labels добавлены в `PodTemplate`, fail-message теперь включает output pod-а (обрезка до 2000), fallback-команда логов по `job-name` |
| 3 | Root-cause для `examples/POSTGRES` | ✅ | Рабочие `examples/notes-python` используют внутренний DSN (`postgres.sless.svc`), а `examples/POSTGRES` работает через managed Nubes Postgres + динамические креды; критичный UX-дефект был в повторном Create после failed apply |
## Статусы: ✅ готово | 🔄 в процессе | ⏳ не начато
@@ -230,37 +432,6 @@
| 6 | `replicas` field в FunctionSpec | Низкий (v1.1) |
| 7 | RabbitMQ event triggers | Низкий (v2) |
| 8 | Метрики → Victoria Metrics | Низкий |
| 9 | `sless-plan` реально не запускает `terraform plan` — нужна интеграция в workflow (docs) | Средний |
---
## 2026-03-12 — AI-анализ terraform plan (ветка feat/ai-terraform-analysis)
| # | Компонент | Статус | Заметки |
|---|-----------|--------|----------|
| 1 | `internal/ai/provider.go` | ✅ | `LLMProvider` interface + `Config` struct + `NewProvider` фабрика |
| 2 | `internal/ai/google_gemini.go` | ✅ | Gemini REST API через `net/http` (без внешних SDK) |
| 3 | `internal/ai/prompts.go` | ✅ | Системные промпты уровней 1–5, `BuildPrompt`, `LevelName` |
| 4 | `internal/ai/analyzer.go` | ✅ | Оркестратор: `NewAnalyzer`, `Analyze`, `FormatResult`, `truncatePlan(8000)` |
| 5 | `internal/ai/cloud_llm.go` | ✅ | Заглушка для будущего облачного LLM |
| 6 | `cmd/sless-plan/main.go` | ✅ | CLI обёртка над `terraform plan` + AI анализ после |
| 7 | `terraform/provider/internal/provider/provider.go` | ✅ | `ai_hint_level` атрибут (опциональный Int64, default=0) в Schema и Configure |
| 8 | `go build ./internal/ai/... && go build ./cmd/sless-plan/...` | ✅ | BUILD OK на VM |
| 9 | `go build ./...` в terraform/provider/ | ✅ | BUILD OK на VM |
| 10 | Коммит + пуш | ✅ | `d5ce8c2` (ai pkg + cmd) → `582b589` (provider) |
**Ветка:** `feat/ai-terraform-analysis`
**Последний коммит:** `582b589`
**Как использовать:**
```hcl
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
token = var.token
ai_hint_level = 3 # 0=off (default), 1–5 = уровень анализа
}
```
Затем вместо `terraform plan` использовать `sless-plan` — предоставит анализ от Gemini.
---
-96
View File
@@ -1,96 +0,0 @@
# Инструкции: монтирование удалённой папки через SSHFS
Дата: 2026-03-12
Кратко: эти команды повторяют текущее у вас подключение, где
источник: `naeel@5.172.178.213:/home/naeel/terra`, и точка монтирования — `/home/<user>/remote_dev`.
1) Установка `sshfs` (Debian/Ubuntu):
```bash
sudo apt update
sudo apt install -y sshfs
```
2) Подготовка приватного ключа:
- Скопируйте приватный ключ на целевую машину или загрузите его безопасным способом.
- Пример копирования с локальной машины на целевой хост:
```bash
scp /home/naeel/remote_dev/sless/secrets/naeel_vm_id_ed25519 user@target:/home/user/.ssh/id_ed25519_sless
ssh user@target 'chmod 600 /home/user/.ssh/id_ed25519_sless'
```
(Оригинал ключа в репозитории: `secrets/naeel_vm_id_ed25519`)
3) Создать точку монтирования на целевом хосте:
```bash
mkdir -p /home/user/remote_dev
chown user:user /home/user/remote_dev
```
4) Команда монтирования (пример, повторяет ваше текущее подключение):
```bash
sshfs -o IdentityFile=/home/user/.ssh/id_ed25519_sless \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o reconnect \
-o ServerAliveInterval=15 \
-o ServerAliveCountMax=3 \
naeel@5.172.178.213:/home/naeel/terra /home/user/remote_dev
```
5) Проверка статуса монтирования и содержимого:
```bash
findmnt /home/user/remote_dev
ls -la /home/user/remote_dev
```
6) Отключение:
```bash
fusermount -u /home/user/remote_dev
```
7) (Опционально) systemd unit для автоподключения при старте:
Создайте файл `/etc/systemd/system/remote_dev.mount` со следующим содержимым (отредактируйте путь к ключу и `Where`):
```ini
[Unit]
Description=SSHFS mount for /home/user/remote_dev
After=network-online.target
Wants=network-online.target
[Mount]
What=naeel@5.172.178.213:/home/naeel/terra
Where=/home/user/remote_dev
Type=fuse.sshfs
Options=IdentityFile=/home/user/.ssh/id_ed25519_sless,allow_other,reconnect,ServerAliveInterval=15,ServerAliveCountMax=3,StrictHostKeyChecking=no,UserKnownHostsFile=/dev/null
[Install]
WantedBy=multi-user.target
```
Затем выполнить:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now remote_dev.mount
```
8) Заметки по безопасности и надёжности:
- Храните приватный ключ с правами `600` и ограниченным доступом.
- Рассмотрите использование `ssh-agent` вместо копирования ключа.
- Опция `StrictHostKeyChecking=no` ослабляет проверку host key — используйте осознанно.
- Для массовой настройки можете создать скрипт установки и unit-файл через Ansible/Cloud-init.
---
Файл создан автоматически по запросу пользователя. Если хотите, могу:
- добавить инструкции для `macOS` или `CentOS`/`RHEL`;
- создать такой же unit-файл и загрузить его на целевой хост (при доступе по SSH).
+1 -1
View File
@@ -16,7 +16,7 @@ crash.log
# Provider plugins / caches
.terraform.d/
#*.tfvars
*.tfvars
# Archives and build artifacts
*.zip
@@ -0,0 +1,94 @@
# 2026-03-18 (обновлено: plain text вывод; фильтрация SLESS_EXCLUDE)
# funcs_list.py — HTTP-функция: список пользовательских функций, человекочитаемый plain text.
# Вызывает внутренний REST API оператора (ClusterIP, без TLS).
# Возвращает str → python runtime отдаёт text/plain напрямую без json.dumps.
#
# Env vars:
# SLESS_API_URL — URL оператора (http://sless-operator.sless.svc.cluster.local:9090)
# SLESS_NAMESPACE — namespace пользователя (sless-{hex16})
# SLESS_TOKEN — JWT токен для /v1/ API
# SLESS_EXTERNAL_URL — публичный базовый URL (https://sless.kube5s.ru)
# SLESS_EXCLUDE — comma-separated имена функций, которые не показывать
import os
import requests
SEP = "─" * 52
def _comment(fn, http_trigs, cron_trigs):
phase = fn.get("phase", "?")
runtime = fn.get("runtime", "?")
if http_trigs:
active = "активна" if http_trigs[0].get("active") else "неактивна"
return f"HTTP endpoint ({runtime}) — {phase}, {active}"
elif cron_trigs:
schedule = cron_trigs[0].get("schedule", "?")
active = "активна" if cron_trigs[0].get("active") else "неактивна"
return f"Cron '{schedule}' ({runtime}) — {phase}, {active}"
else:
return f"Job/runner без триггера ({runtime}) — {phase}"
def list_all(event):
api_url = os.environ["SLESS_API_URL"].rstrip("/")
namespace = os.environ["SLESS_NAMESPACE"]
token = os.environ["SLESS_TOKEN"]
ext_url = os.environ.get("SLESS_EXTERNAL_URL", "").rstrip("/")
exclude = {n.strip() for n in os.environ.get("SLESS_EXCLUDE", "").split(",") if n.strip()}
headers = {"Authorization": f"Bearer {token}"}
fns = requests.get(f"{api_url}/v1/namespaces/{namespace}/functions", headers=headers, timeout=10)
trs = requests.get(f"{api_url}/v1/namespaces/{namespace}/triggers", headers=headers, timeout=10)
fns.raise_for_status()
trs.raise_for_status()
trig_idx = {}
for tr in trs.json():
fn_name = tr.get("function") or tr.get("functionRef")
if fn_name:
trig_idx.setdefault(fn_name, []).append(tr)
items = []
for fn in fns.json():
name = fn["name"]
if name in exclude:
continue
http_t = [t for t in trig_idx.get(name, []) if t.get("type") == "http"]
cron_t = [t for t in trig_idx.get(name, []) if t.get("type") == "cron"]
is_active = any(t.get("enabled", True) and t.get("active", False) for t in trig_idx.get(name, []))
items.append((fn, http_t, cron_t, is_active))
# Сортировка: активные вверх, затем по имени
items.sort(key=lambda x: (not x[3], x[0]["name"]))
lines = []
for fn, http_t, cron_t, is_active in items:
name = fn["name"]
lines.append(SEP)
lines.append(f" {_comment(fn, http_t, cron_t)}")
lines.append(f" name: {name}")
lines.append(f" runtime: {fn.get('runtime', '?')}")
lines.append(f" phase: {fn.get('phase', '?')}")
lines.append(f" active: {'да' if is_active else 'нет'}")
if http_t:
url = f"{ext_url}/fn/{namespace}/{name}" if ext_url else http_t[0].get("url", "")
lines.append(f" url: {url}")
if cron_t:
lines.append(f" cron: {cron_t[0].get('schedule', '?')}")
if fn.get("created_at"):
lines.append(f" created: {fn['created_at']}")
if fn.get("last_built_at"):
lines.append(f" built: {fn['last_built_at']}")
if fn.get("message"):
lines.append(f" message: {fn['message']}")
lines.append(SEP)
lines.append(f" namespace: {namespace} | total: {len(items)}")
lines.append(SEP)
# Возвращаем str — python runtime отдаст text/plain напрямую
return "\n".join(lines) + "\n"
@@ -0,0 +1 @@
requests==2.31.0
@@ -0,0 +1,8 @@
{
"name": "pg-info",
"version": "1.0.0",
"description": "sless nodejs20 function: pg version + table info",
"dependencies": {
"pg": "8.11.0"
}
}
+43
View File
@@ -0,0 +1,43 @@
// 2026-03-18
// pg_info.js — NodeJS-функция: проверка работы JS runtime + чтение мета-данных БД.
// Подключается к PostgreSQL через пакет pg, возвращает версию сервера и счётчик строк.
// Демонстрирует: nodejs20 runtime, npm-зависимость (package.json), PG из JS.
//
// ENV (те же что у python-функций):
// PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, PGSSLMODE
//
// Entrypoint: pg_info.info
'use strict';
const { Client } = require('pg');
exports.info = async (event) => {
const client = new Client({
host: process.env.PGHOST,
port: parseInt(process.env.PGPORT || '5432'),
database: process.env.PGDATABASE,
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
// pg-пакет требует явного ssl-объекта; rejectUnauthorized: false — т.к.
// self-signed cert на nubes managed PG, но канал всё равно шифруется.
ssl: process.env.PGSSLMODE === 'require' ? { rejectUnauthorized: false } : false,
});
await client.connect();
try {
const [versionRes, countRes] = await Promise.all([
client.query('SELECT version() AS v'),
client.query('SELECT COUNT(*) AS cnt FROM terraform_demo_table'),
]);
return {
runtime: 'nodejs20',
node_version: process.version,
pg_version: versionRes.rows[0].v,
table_rows: parseInt(countRes.rows[0].cnt, 10),
};
} finally {
await client.end();
}
};
@@ -0,0 +1,3 @@
# 2026-03-17 00:00
# requirements.txt — зависимости для функции запуска SQL.
psycopg2-binary==2.9.9
@@ -0,0 +1,39 @@
# 2026-03-17 00:00
# sql_runner.py — функция для выполнения SQL-операторов из входного события.
import os
import psycopg2
def run_sql(event):
# Выполняет список SQL-операторов в одной транзакции для атомарной инициализации схемы.
# Параметры подключения передаются раздельно, чтобы избежать ошибок парсинга DSN при спецсимволах.
pg_host = os.environ["PGHOST"]
pg_port = os.environ.get("PGPORT", "5432")
pg_database = os.environ["PGDATABASE"]
pg_user = os.environ["PGUSER"]
pg_password = os.environ["PGPASSWORD"]
pg_sslmode = os.environ.get("PGSSLMODE", "require")
statements = event.get("statements", [])
if not statements:
return {"error": "no statements provided"}
connection = psycopg2.connect(
host=pg_host,
port=pg_port,
dbname=pg_database,
user=pg_user,
password=pg_password,
sslmode=pg_sslmode,
)
try:
cursor = connection.cursor()
for statement in statements:
cursor.execute(statement)
connection.commit()
return {"ok": True, "executed": len(statements)}
except Exception as error:
connection.rollback()
return {"error": str(error)}
finally:
connection.close()
@@ -0,0 +1 @@
psycopg2-binary==2.9.9
+130
View File
@@ -0,0 +1,130 @@
# 2026-03-19
# table_rw.py — чтение и запись строк в terraform_demo_table.
# Два entrypoint в одном файле: list_rows (JSON API) и add_row (HTML-страница + POST-обработчик).
# ENV: PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, PGSSLMODE
import os
import json
import psycopg2
import psycopg2.extras
def _connect():
return psycopg2.connect(
host=os.environ["PGHOST"],
port=os.environ.get("PGPORT", "5432"),
dbname=os.environ["PGDATABASE"],
user=os.environ["PGUSER"],
password=os.environ["PGPASSWORD"],
sslmode=os.environ.get("PGSSLMODE", "require"),
)
def list_rows(event):
# Возвращает все строки terraform_demo_table, отсортированные по убыванию created_at.
conn = _connect()
try:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC"
)
rows = [dict(r) for r in cur.fetchall()]
return {"rows": rows, "count": len(rows)}
finally:
conn.close()
def _render_page(rows, message=""):
# HTML-страница с формой ввода и таблицей строк.
# message — статус последней операции (успех / ошибка).
rows_html = "".join(
f"<tr><td>{r['id']}</td><td>{r['title']}</td><td>{r['created_at']}</td></tr>"
for r in rows
)
msg_html = f'<p class="msg">{message}</p>' if message else ""
return f"""<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>pg-table-writer</title>
<style>
body {{ font-family: sans-serif; max-width: 700px; margin: 40px auto; background: #111; color: #eee; }}
h1 {{ color: #7dd3fc; }}
form {{ display: flex; gap: 8px; margin-bottom: 24px; }}
input[type=text] {{ flex: 1; padding: 8px 12px; border-radius: 6px; border: 1px solid #444; background: #1e1e1e; color: #eee; font-size: 15px; }}
button {{ padding: 8px 18px; background: #2563eb; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 15px; }}
button:hover {{ background: #1d4ed8; }}
table {{ width: 100%; border-collapse: collapse; }}
th, td {{ padding: 8px 10px; border-bottom: 1px solid #333; text-align: left; }}
th {{ color: #7dd3fc; }}
.msg {{ color: #4ade80; margin-bottom: 12px; }}
</style>
</head>
<body>
<h1>pg-table-writer</h1>
<form method="POST">
<input type="text" name="title" placeholder="Введите строку..." autofocus required>
<button type="submit">Добавить</button>
</form>
{msg_html}
<table>
<thead><tr><th>#</th><th>title</th><th>created_at</th></tr></thead>
<tbody>{rows_html}</tbody>
</table>
</body>
</html>"""
def add_row(event):
# GET → HTML-страница с формой и списком строк.
# POST → вставляет строку из form-поля title или JSON-поля title,
# затем возвращает обновлённую HTML-страницу.
# POST с Content-Type: application/json (curl/API) → возвращает JSON.
method = event.get("_method", "GET")
if method == "GET":
conn = _connect()
try:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC")
rows = [dict(r) for r in cur.fetchall()]
finally:
conn.close()
return _render_page(rows)
# POST — вставка строки
# Поле title приходит либо из JSON-тела, либо из application/x-www-form-urlencoded.
# Сервер уже распарсил JSON в event; form-данные приходят как event["body"] = "title=...".
title = event.get("title", "").strip()
if not title:
# Попытка распарсить form-encoded body (браузерная форма)
body = event.get("body", "")
if body.startswith("title="):
from urllib.parse import unquote_plus
title = unquote_plus(body[len("title="):].split("&")[0]).strip()
if not title:
return {"ok": False, "error": "title is required"}
conn = _connect()
try:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"INSERT INTO terraform_demo_table (title) VALUES (%s) RETURNING id, title, created_at::text",
(title,),
)
row = dict(cur.fetchone())
conn.commit()
# Если запрос из браузера (form POST) — возвращаем обновлённую страницу.
# Если из curl/API — возвращаем JSON.
accept = event.get("_accept", "")
if "application/json" in accept:
return {"ok": True, "row": row}
# Перечитываем все строки для обновлённой страницы
cur.execute("SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC")
rows = [dict(r) for r in cur.fetchall()]
return _render_page(rows, message=f"Добавлено: «{row['title']}»")
finally:
conn.close()
+129
View File
@@ -0,0 +1,129 @@
# 2026-03-18 (обновлено: фильтрация SLESS_EXCLUDE, читаемый вывод через "#"-ключ)
# funcs_list.py — HTTP-функция: список всех пользовательских функций с их статусами.
# Вызывает внутренний REST API оператора (ClusterIP, без TLS).
# Объединяет данные функций и триггеров в один ответ; скрывает служебные функции.
#
# Env vars:
# SLESS_API_URL — URL оператора (http://sless-operator.sless.svc.cluster.local:9090)
# SLESS_NAMESPACE — namespace пользователя (sless-{hex16})
# SLESS_TOKEN — JWT токен для /v1/ API
# SLESS_EXTERNAL_URL — публичный базовый URL (https://sless.kube5s.ru), для корректных ссылок
# SLESS_EXCLUDE — comma-separated имена функций, которые не надо показывать
# Пример: "funcs,event-writer,event-monitor,event-cleaner"
#
# Формат вывода: JSON-объект, где каждая функция содержит поле "#" — краткий комментарий.
# При pretty-print (python3 -m json.tool) выглядит как читаемый список с аннотациями.
import os
import requests
def _short_comment(fn, http_triggers, cron_triggers):
"""Генерирует однострочный комментарий-описание функции по её метаданным."""
phase = fn.get("phase", "")
runtime = fn.get("runtime", "")
if http_triggers:
active_str = "активна" if http_triggers[0].get("active") else "неактивна"
return f"HTTP endpoint ({runtime}) — {phase}, {active_str}"
elif cron_triggers:
schedule = cron_triggers[0].get("schedule", "?")
active_str = "активна" if cron_triggers[0].get("active") else "неактивна"
return f"Cron '{schedule}' ({runtime}) — {phase}, {active_str}"
else:
return f"Job/runner без триггера ({runtime}) — {phase}"
def list_all(event):
api_url = os.environ["SLESS_API_URL"].rstrip("/")
namespace = os.environ["SLESS_NAMESPACE"]
token = os.environ["SLESS_TOKEN"]
ext_url = os.environ.get("SLESS_EXTERNAL_URL", "").rstrip("/")
# Имена функций, которые не должны присутствовать в выводе.
# Включает саму себя ("funcs") и служебные функции других примеров.
exclude = {
n.strip()
for n in os.environ.get("SLESS_EXCLUDE", "").split(",")
if n.strip()
}
headers = {"Authorization": f"Bearer {token}"}
fns_resp = requests.get(
f"{api_url}/v1/namespaces/{namespace}/functions",
headers=headers,
timeout=10,
)
fns_resp.raise_for_status()
trs_resp = requests.get(
f"{api_url}/v1/namespaces/{namespace}/triggers",
headers=headers,
timeout=10,
)
trs_resp.raise_for_status()
# Индекс триггеров по имени функции
triggers_by_fn = {}
for tr in trs_resp.json():
fn_name = tr.get("function") or tr.get("functionRef")
if fn_name:
triggers_by_fn.setdefault(fn_name, []).append(tr)
result = []
for fn in fns_resp.json():
name = fn["name"]
if name in exclude:
continue
http_triggers = [
t for t in triggers_by_fn.get(name, []) if t.get("type") == "http"
]
cron_triggers = [
t for t in triggers_by_fn.get(name, []) if t.get("type") == "cron"
]
is_active = any(
t.get("enabled", True) and t.get("active", False)
for t in triggers_by_fn.get(name, [])
)
entry = {
# "#" — первый ключ: служит визуальным комментарием при pretty-print
"#": _short_comment(fn, http_triggers, cron_triggers),
"name": name,
"runtime": fn.get("runtime"),
"phase": fn.get("phase"),
"active": is_active,
}
# URL вычисляем из SLESS_EXTERNAL_URL если задан — state может хранить старый домен
if http_triggers:
if ext_url:
entry["url"] = f"{ext_url}/fn/{namespace}/{name}"
else:
entry["url"] = http_triggers[0].get("url", "")
if cron_triggers:
entry["cron"] = cron_triggers[0].get("schedule", "")
if fn.get("message"):
entry["message"] = fn["message"]
# created_at и last_built_at — доступны после обновления оператора до v0.1.32+
if fn.get("created_at"):
entry["created_at"] = fn["created_at"]
if fn.get("last_built_at"):
entry["last_built_at"] = fn["last_built_at"]
result.append(entry)
# Сортировка: активные вверх, затем по имени
result.sort(key=lambda f: (not f["active"], f["name"]))
return {
"namespace": namespace,
"count": len(result),
"functions": result,
}
+73
View File
@@ -0,0 +1,73 @@
# resource "nubes_lucee" "app1" {
# # Lucee-приложение, зависит от Postgres
# resource_name = "lucy_teststand_0"
# # resource_realm = "k8s-3.ext.nubes.ru"
# resource_realm = nubes_postgres.db2.resource_realm
# # resource_realm = "k8s-4-sandbox-nubes-ru"
# domain = "web-test-stand"
# git_path = "https://gitea-naeel.giteak8s.services.ngcloud.ru/naeel/testlucee"
# json_env = jsonencode({
# # 🔗 Настройки Data Source 'testds' для Lucee (Application.cfc)
# testds_class = "org.postgresql.Driver" # 📂 Драйвер БД
# testds_bundleName = "org.postgresql.jdbc" # 📦 Имя бандла JDBC
# testds_bundleVersion = "42.6.0" # 🔢 Версия драйвера
# testds_connectionString = "jdbc:postgresql://${nubes_postgres.db2.state_out_flat["internalConnect.master"]}:5432/postgres?sslmode=require" # 🚀 Строка подключения
# testds_username = nubes_postgres_user.db2_user.username # 👤 Логин
# testds_password = jsondecode(nubes_postgres.db2.vault_secrets["users"])[nubes_postgres_user.db2_user.username]["password"] # 🔑 Пароль
# testds_connectionLimit = "5" # 🚦 Лимит соединений
# testds_liveTimeout = "15" # ⏳ Таймаут жизни
# testds_validate = "false" # ✅ Валидация при запросе
# })
# resource_c_p_u = 300
# resource_memory = 512
# resource_instances = 1
# app_version = "5.4"
# depends_on = [nubes_postgres.db2]
# }
# resource "nubes_nodejs" "app3" {
# # NodeJS демо, работающий с тем же Postgres.
# resource_name = "node_01"
# resource_realm = nubes_postgres.db2.resource_realm
# domain = "node07"
# git_path = "https://gitea-naeel.giteak8s.services.ngcloud.ru/naeel/testnode.git"
# health_path = "/healthz"
# app_version = "23"
# json_env = jsonencode({
# # Переменные подключения к Postgres.
# PGHOST = nubes_postgres.db2.state_out_flat["internalConnect.master"]
# PGPORT = "5432"
# PGUSER = nubes_postgres_user.db2_user.username
# PGPASSWORD = jsondecode(nubes_postgres.db2.vault_secrets["users"])[nubes_postgres_user.db2_user.username]["password"]
# PGDATABASE = nubes_postgres_database.db2_app.db_name
# PGSSLMODE = "require"
# DATABASE_URL = format(
# "postgresql://%s:%s@%s:5432/%s?sslmode=require",
# nubes_postgres_user.db2_user.username,
# jsondecode(nubes_postgres.db2.vault_secrets["users"])[nubes_postgres_user.db2_user.username]["password"],
# nubes_postgres.db2.state_out_flat["internalConnect.master"],
# nubes_postgres_database.db2_app.db_name
# )
# })
# resource_c_p_u = 300
# resource_memory = 256
# resource_instances = 1
# depends_on = [nubes_postgres.db2]
# }
# output "pg_vault_secrets" {
# value = nubes_postgres.db2.vault_secrets
# sensitive = true
# }
# terraform output -json pg_vault_secrets
+58
View File
@@ -0,0 +1,58 @@
// 2026-03-17 17:05
// main.tf — провайдеры и переменные для Nubes + sless.
terraform {
required_providers {
nubes = {
source = "terra.k8c.ru/nubes/nubes"
version = "5.0.19"
}
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.18"
}
}
}
variable "api_token" {
type = string
sensitive = true
description = "Nubes API token"
}
variable "s3_uid" {
type = string
sensitive = true
description = "Nubes S3 UID"
}
variable "realm" {
type = string
sensitive = true
description = "resource_realm parameter for nubes_postgres resource"
}
// 2026-03-18 — pg_user/pg_password помечены optional (default="") для сверки.
// Реальные credentials берутся из vault_secrets через locals в resources.tf.
variable "pg_user" {
type = string
sensitive = true
default = ""
description = "Только для сверки. Реальный username из nubes_postgres_user.pg_user.username. Должен совпадать с vault."
}
variable "pg_password" {
type = string
sensitive = true
default = ""
description = "Только для сверки. Реальный пароль из vault_secrets. Должен совпадать с tfvars."
}
provider "nubes" {
api_token = var.api_token
api_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm"
}
provider "sless" {
endpoint = "https://sless.kube5s.ru"
token = var.api_token
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
}
+196
View File
@@ -0,0 +1,196 @@
// 2026-03-18 — добавлены locals для извлечения credentials из vault_secrets (без хардкода).
// Для сверки хардкод остаётся в terraform.tfvars на этапе разработки.
// sless_function и sless_job закомментированы — сначала проверяется сетевое соединение.
# Актуальные credentials из vault_secrets (authoritatively) — vault синхронизирован с кластером.
# Структура vault_secrets["users"]: JSON-строка {"username": {"password": "...", "username": "..."}}
locals {
pg_creds_map = jsondecode(nubes_postgres.npg.vault_secrets["users"])
pg_username = nubes_postgres_user.pg_user.username
pg_password = local.pg_creds_map[local.pg_username]["password"]
pg_host = nubes_postgres.npg.state_out_flat["internalConnect.master"]
pg_database = nubes_postgres_database.db.db_name
}
resource "nubes_postgres" "npg" {
resource_name = "teststand-pg-2"
# s3_uid = "s01325"
s3_uid = var.s3_uid
resource_realm = var.realm
resource_instances = 1
resource_memory = 512
resource_c_p_u = 500
resource_disk = "1"
app_version = "17"
json_parameters = jsonencode({
log_connections = "off"
log_disconnections = "off"
})
enable_pg_pooler_master = false
enable_pg_pooler_slave = false
allow_no_s_s_l = false
auto_scale = false
auto_scale_percentage = 10
auto_scale_tech_window = 0
auto_scale_quota_gb = "1"
need_external_address_master = false
# suspend_on_destroy = false
operation_timeout = "11m"
adopt_existing_on_create = true
}
resource "nubes_postgres_user" "pg_user" {
postgres_id = nubes_postgres.npg.id
username = "u-user0"
role = "ddl_user"
adopt_existing_on_create = true
}
resource "nubes_postgres_database" "db" {
postgres_id = nubes_postgres.npg.id
db_name = "db_terra"
db_owner = nubes_postgres_user.pg_user.username
adopt_existing_on_create = true
# suspend_on_destroy = false
}
# Служебная функция выполняет SQL-операторы из event_json.
# Credentials берутся из locals (vault_secrets) — без хардкода.
# Для сверки хардкод остаётся в terraform.tfvars.
resource "sless_function" "postgres_sql_runner_create_table" {
name = "pg-create-table-runner"
runtime = "python3.11"
entrypoint = "sql_runner.run_sql"
memory_mb = 128
timeout_sec = 30
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
# Для сверки (должно совпадать с vault):
# PGUSER = var.pg_user
# PGPASSWORD = var.pg_password
}
source_dir = "${path.module}/code/sql-runner"
}
resource "sless_job" "postgres_table_init_job" {
name = "pg-create-table-job-main-v13"
function = sless_function.postgres_sql_runner_create_table.name
wait_timeout_sec = 180
run_id = 13
event_json = jsonencode({
statements = [
"CREATE TABLE IF NOT EXISTS terraform_demo_table (id serial PRIMARY KEY, title text NOT NULL, created_at timestamp DEFAULT now())"
]
})
depends_on = [nubes_postgres_database.db]
}
# HTTP-функция на NodeJS: возвращает версию PG-сервера и счётчик строк в таблице.
# Единственная функция примера на nodejs20 — проверка что JS runtime работает.
# Доступна по URL: https://sless.kube5s.ru/fn/<namespace>/pg-info
resource "sless_function" "pg_info" {
name = "pg-info"
runtime = "nodejs20"
entrypoint = "pg_info.info"
memory_mb = 128
timeout_sec = 15
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
}
source_dir = "${path.module}/code/pg-info"
depends_on = [sless_job.postgres_table_init_job]
}
resource "sless_trigger" "pg_info_http" {
name = "pg-info-http"
type = "http"
function = sless_function.pg_info.name
enabled = true
}
# HTTP-функции чтения и записи строк terraform_demo_table — в одном файле table_rw.py.
# list_rows (GET) — читает все строки; add_row (POST {title}) — вставляет строку.
# Доступны по URL: https://sless.kube5s.ru/fn/<namespace>/pg-table-reader
# https://sless.kube5s.ru/fn/<namespace>/pg-table-writer
resource "sless_function" "postgres_table_reader" {
name = "pg-table-reader"
runtime = "python3.11"
entrypoint = "table_rw.list_rows"
memory_mb = 128
timeout_sec = 30
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
}
source_dir = "${path.module}/code/table-rw"
depends_on = [sless_job.postgres_table_init_job]
}
resource "sless_trigger" "postgres_table_reader_http" {
name = "pg-table-reader-http"
type = "http"
function = sless_function.postgres_table_reader.name
enabled = true
}
output "table_reader_url" {
value = sless_trigger.postgres_table_reader_http.url
}
resource "sless_function" "postgres_table_writer" {
name = "pg-table-writer"
runtime = "python3.11"
entrypoint = "table_rw.add_row"
memory_mb = 128
timeout_sec = 30
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
}
source_dir = "${path.module}/code/table-rw"
depends_on = [sless_job.postgres_table_init_job]
}
resource "sless_trigger" "postgres_table_writer_http" {
name = "pg-table-writer-http"
type = "http"
function = sless_function.postgres_table_writer.name
enabled = true
}
output "table_writer_url" {
value = sless_trigger.postgres_table_writer_http.url
}
@@ -0,0 +1,51 @@
# 2026-03-18 — debug pod для проверки psql-соединения из namespace функций.
# Запускается разово. Подключается к тому же postgres, что и sless_function.
# kubectl apply -f /tmp/pg-debug-pod.yaml
# kubectl logs -n sless-fn-sless-ffd1f598c169b0ae pg-debug-pod
apiVersion: v1
kind: Pod
metadata:
name: pg-debug-pod
namespace: sless-fn-sless-ffd1f598c169b0ae
labels:
purpose: debug-postgres-connectivity
spec:
restartPolicy: Never
containers:
- name: psql
image: postgres:17-alpine
command:
- sh
- -c
- |
echo "=== Testing TCP connectivity to postgres ==="
nc -zv -w5 $PGHOST 5432 && echo "TCP OK" || echo "TCP FAILED"
echo ""
echo "=== Testing psql connection ==="
PGCONNECT_TIMEOUT=10 psql \
"host=$PGHOST port=$PGPORT dbname=$PGDATABASE user=$PGUSER sslmode=$PGSSLMODE" \
--command="SELECT current_user, current_database(), version();" \
2>&1
echo ""
echo "=== Listing tables ==="
PGCONNECT_TIMEOUT=10 psql \
"host=$PGHOST port=$PGPORT dbname=$PGDATABASE user=$PGUSER sslmode=$PGSSLMODE" \
--command="\dt" \
2>&1
env:
- name: PGHOST
value: "postgresqlk8s-master.36875359-dcea-48c4-a593-b4531f20fe96.svc.cluster.local"
- name: PGPORT
value: "5432"
- name: PGDATABASE
value: "db_terra"
- name: PGUSER
value: "u-user0"
- name: PGPASSWORD
# Актуальный пароль из vault_secrets (совпадает с tfvars.pg_password на 2026-03-18)
value: "M03O6fRsngWcVHB2YGivyLfbfxoii2R21nyh2A2r7WSZS5deLwBgLKkc9Wk24Zyl"
- name: PGSSLMODE
value: "require"
@@ -0,0 +1,40 @@
# 2026-03-17 13:05
# read_pg_user_secret.py — читает пароль пользователя managed PostgreSQL из k8s Secret.
# Используется из Terraform external data source, чтобы apply сам получал актуальный пароль
# даже для уже существующего пользователя, созданного вне текущего state.
import base64
import json
import subprocess
import sys
def main():
# Читаем query от Terraform external provider из stdin.
query = json.load(sys.stdin)
namespace = query["namespace"]
secret_name = query["secret"]
# kubectl уже настроен на удалённой машине; читаем ровно поле data.password.
result = subprocess.run(
[
"kubectl",
"get",
"secret",
"-n",
namespace,
secret_name,
"-o",
"jsonpath={.data.password}",
],
check=True,
capture_output=True,
text=True,
)
password = base64.b64decode(result.stdout.strip()).decode()
json.dump({"password": password}, sys.stdout)
if __name__ == "__main__":
main()
+7 -7
View File
@@ -12,7 +12,7 @@
| `sless_trigger` | Публикует функцию: тип `http` создаёт публичный URL, тип `cron` — запуск по расписанию. |
| `sless_job` | Запускает функцию однократно и ожидает завершения. Используется для одноразовых операций: инициализация БД, миграции, пакетная обработка. |
Стандартная связка для HTTP API: `sless_function` + `sless_trigger` с `type = "http"` — в результате функция доступна по URL вида `https://sless-api.kube5s.ru/fn/<namespace>/<имя-функции>`.
Стандартная связка для HTTP API: `sless_function` + `sless_trigger` с `type = "http"` — в результате функция доступна по URL вида `https://sless.kube5s.ru/fn/<namespace>/<имя-функции>`.
---
@@ -20,7 +20,7 @@
- Terraform >= 1.0
- JWT-токен для аутентификации в sless API
- Доступ к `https://sless-api.kube5s.ru`
- Доступ к `https://sless.kube5s.ru`
## Конфигурация провайдера
@@ -28,9 +28,9 @@
```hcl
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
endpoint = "https://sless.kube5s.ru"
token = var.token
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
}
```
@@ -56,7 +56,7 @@ terraform init
terraform apply -auto-approve
# Вызов HTTP-функции с передачей имени:
curl -s -X POST https://sless-api.kube5s.ru/fn/<namespace>/hello-http \
curl -s -X POST https://sless.kube5s.ru/fn/<namespace>/hello-http \
-H 'Content-Type: application/json' -d '{"name":"World"}'
# Результат задания:
@@ -114,7 +114,7 @@ terraform init
terraform apply -auto-approve
terraform output job_result
curl -s https://sless-api.kube5s.ru/fn/<namespace>/simple-py-time-display
curl -s https://sless.kube5s.ru/fn/<namespace>/simple-py-time-display
```
---
@@ -127,7 +127,7 @@ terraform init
terraform apply -auto-approve
terraform output job_result
curl -s https://sless-api.kube5s.ru/fn/<namespace>/simple-node-time-display
curl -s https://sless.kube5s.ru/fn/<namespace>/simple-node-time-display
```
---
@@ -0,0 +1,45 @@
# Изменено: 2026-03-14
# Функция event-cleaner: удаляет N самых старых строк из таблицы events.
# Вызывается через HTTP POST из Node-RED (который слушает RabbitMQ).
# Env: POSTGRES_DSN — строка подключения к PostgreSQL.
import os
import json
import psycopg2
def handle(request):
"""Удаляет N старейших строк из таблицы events."""
dsn = os.environ["POSTGRES_DSN"]
body = {}
if request.get_data():
try:
body = json.loads(request.get_data())
except Exception:
pass
# Количество строк для удаления — из тела запроса или дефолт 10
delete_n = int(body.get("delete_n", 10))
# Защита от случайного удаления слишком большого количества строк
delete_n = min(delete_n, 100)
conn = psycopg2.connect(dsn)
try:
with conn.cursor() as cur:
cur.execute("""
DELETE FROM events
WHERE id IN (
SELECT id FROM events ORDER BY created_at ASC LIMIT %s
)
""", (delete_n,))
deleted = cur.rowcount
cur.execute("SELECT COUNT(*) FROM events")
remaining = cur.fetchone()[0]
conn.commit()
return json.dumps({
"ok": True,
"deleted": deleted,
"remaining": remaining
}), 200, {"Content-Type": "application/json"}
finally:
conn.close()
@@ -0,0 +1,47 @@
# Изменено: 2026-03-14
# Функция event-monitor: считает строки в events.
# Если больше 50 — публикует сообщение в RabbitMQ queue "cleanup-needed".
# Запускается по cron (каждую минуту).
# Env:
# POSTGRES_DSN — строка подключения к PostgreSQL
# RABBITMQ_URL — amqp://sless:sless123@rabbitmq.sless.svc.cluster.local:5672/
import os
import json
import psycopg2
import pika
THRESHOLD = 50
def handle(request):
"""Мониторит таблицу events. При переполнении шлёт в RabbitMQ."""
dsn = os.environ["POSTGRES_DSN"]
rabbit_url = os.environ["RABBITMQ_URL"]
conn = psycopg2.connect(dsn)
try:
with conn.cursor() as cur:
# Считаем количество событий
cur.execute("SELECT COUNT(*) FROM events")
count = cur.fetchone()[0]
finally:
conn.close()
result = {"count": count, "threshold": THRESHOLD, "action": "none"}
if count > THRESHOLD:
# Публикуем в очередь — event-cleaner получит и удалит старые строки
params = pika.URLParameters(rabbit_url)
connection = pika.BlockingConnection(params)
channel = connection.channel()
channel.queue_declare(queue="cleanup-needed", durable=True)
channel.basic_publish(
exchange="",
routing_key="cleanup-needed",
body=json.dumps({"count": count, "delete_n": 10}),
properties=pika.BasicProperties(delivery_mode=2) # persistent
)
connection.close()
result["action"] = "cleanup_requested"
return json.dumps(result), 200, {"Content-Type": "application/json"}
@@ -0,0 +1,49 @@
# Изменено: 2026-03-14
# Функция event-writer: принимает HTTP POST, пишет одну строку в таблицу events.
# Таблица создаётся автоматически при первом запуске.
# Env: POSTGRES_DSN — строка подключения к PostgreSQL.
import os
import json
import psycopg2
from datetime import datetime, timezone
def handle(request):
"""Записывает одно событие в таблицу events."""
dsn = os.environ["POSTGRES_DSN"]
body = {}
if request.get_data():
try:
body = json.loads(request.get_data())
except Exception:
pass
source = body.get("source", "node-red")
message = body.get("message", "ping")
conn = psycopg2.connect(dsn)
try:
with conn.cursor() as cur:
# Создаём таблицу если нет — безопасно вызывать при каждом запросе
cur.execute("""
CREATE TABLE IF NOT EXISTS events (
id SERIAL PRIMARY KEY,
source VARCHAR(100) NOT NULL DEFAULT 'unknown',
message TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
cur.execute(
"INSERT INTO events (source, message) VALUES (%s, %s) RETURNING id, created_at",
(source, message)
)
row = cur.fetchone()
conn.commit()
return json.dumps({
"ok": True,
"id": row[0],
"created_at": row[1].isoformat()
}), 200, {"Content-Type": "application/json"}
finally:
conn.close()
+194
View File
@@ -0,0 +1,194 @@
# Примеры использования sless
## Обзор платформы
**sless** — система управления serverless-функциями на базе Kubernetes. Разработчик загружает код функции, платформа собирает из него Docker-образ, разворачивает его в кластере и предоставляет HTTP-эндпоинт для вызова. Всё описывается декларативно через Terraform.
### Основные ресурсы провайдера
| Ресурс | Назначение |
|---|---|
| `sless_function` | Описывает функцию: язык, точку входа, лимиты, переменные окружения. При создании загружает код и запускает его сборку в образ. Сама по себе недоступна снаружи — нужен триггер или задание. |
| `sless_trigger` | Публикует функцию: тип `http` создаёт публичный URL, тип `cron` — запуск по расписанию. |
| `sless_job` | Запускает функцию однократно и ожидает завершения. Используется для одноразовых операций: инициализация БД, миграции, пакетная обработка. |
Стандартная связка для HTTP API: `sless_function` + `sless_trigger` с `type = "http"` — в результате функция доступна по URL вида `https://sless.kube5s.ru/fn/<namespace>/<имя-функции>`.
---
## Требования
- Terraform >= 1.0
- JWT-токен для аутентификации в sless API
- Доступ к `https://sless.kube5s.ru`
## Конфигурация провайдера
Во всех примерах файл `main.tf` содержит блок провайдера. Токен передаётся через переменную, значение которой задаётся в `terraform.tfvars`:
```hcl
provider "sless" {
endpoint = "https://sless.kube5s.ru"
token = var.token
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
}
```
Namespace функций вычисляется автоматически из JWT-токена: `sless-{sha256[:8]}`.
> **Перед запуском любого примера** откройте файл `terraform.tfvars` в директории примера и впишите свой токен Nubes API:
> ```hcl
> token = "ваш токен Nubes API"
> ```
> Токен выдаётся в личном кабинете Nubes. Файл `terraform.tfvars` добавлен в `.gitignore` — он не попадёт в репозиторий.
---
## Примеры
### `hello-node` — минимальный пример на Node.js
Две независимые функции: HTTP-функция, возвращающая приветствие, и одноразовое задание, суммирующее набор чисел. Хорошая отправная точка для знакомства с платформой.
```bash
cd hello-node
terraform init
terraform apply -auto-approve
# Вызов HTTP-функции с передачей имени:
curl -s -X POST https://sless.kube5s.ru/fn/<namespace>/hello-http \
-H 'Content-Type: application/json' -d '{"name":"World"}'
# Результат задания:
terraform output job_message
```
---
### `hello-go` — минимальный пример на Go 1.23
Аналог `hello-node`, но на Go. Демонстрирует поддержку Go-рантайма: HTTP-функция и одноразовое задание. Код пользователя оформляется как пакет `handler` с функцией `Handle(event)`.
```bash
cd hello-go
terraform init
terraform apply -auto-approve
terraform output job_message
terraform output trigger_url
```
---
### `pg-list-python` — выборка данных из PostgreSQL (Python)
Минимальный пример работы с базой данных: одна HTTP-функция читает список записей из таблицы PostgreSQL и возвращает их в JSON. Таблица с тестовыми данными создаётся автоматически при первом вызове. Нет заданий, нет инициализации — только функция и триггер.
**Переменные:**
| Переменная | Описание | Значение по умолчанию |
|---|---|---|
| `pg_dsn` | Строка подключения к PostgreSQL | `postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable` |
```bash
cd pg-list-python
terraform init
terraform apply -auto-approve
# URL функции выводится после применения:
terraform output catalog_url
# Запрос к функции:
curl -s $(terraform output -raw catalog_url)
```
---
### `simple-python` — одноразовое задание передаёт данные в HTTP-функцию (Python)
При `apply` выполняется задание, которое фиксирует текущее время. Результат передаётся в HTTP-функцию через переменные окружения и отображается при каждом запросе.
```bash
cd simple-python
terraform init
terraform apply -auto-approve
terraform output job_result
curl -s https://sless.kube5s.ru/fn/<namespace>/simple-py-time-display
```
---
### `simple-node` — то же самое на Node.js 20
```bash
cd simple-node
terraform init
terraform apply -auto-approve
terraform output job_result
curl -s https://sless.kube5s.ru/fn/<namespace>/simple-node-time-display
```
---
### `notes-python` — CRUD API на Python с PostgreSQL
Полноценное приложение: инициализация схемы базы данных через задания, CRUD-функция для работы с записями, отдельная функция для получения списка.
**Переменные:**
| Переменная | Описание | Значение по умолчанию |
|---|---|---|
| `pg_dsn` | Строка подключения к PostgreSQL | `postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable` |
```bash
cd notes-python
terraform init
terraform apply -auto-approve
# Статус инициализации базы данных:
terraform output db_init_table_status
terraform output db_init_index_status
# Создать запись:
curl -s -X POST "$(terraform output -raw notes_url)/add?title=Hello&body=World"
# Получить список записей:
curl -s $(terraform output -raw notes_list_url)
# Обновить запись (id из предыдущего ответа):
curl -s -X POST "$(terraform output -raw notes_url)/update?id=1&title=Updated&body=New+body"
# Удалить запись:
curl -s -X POST "$(terraform output -raw notes_url)/delete?id=1"
```
---
## Полезные команды
```bash
# Посмотреть текущее состояние задеплоенных ресурсов:
terraform show
# Принудительно пересобрать функцию (например, после изменения кода):
terraform apply -replace=sless_function.<имя> -auto-approve
# Повторно запустить задание: увеличить значение run_id в .tf-файле, затем:
terraform apply -auto-approve
# Удалить все ресурсы примера:
terraform destroy -auto-approve
```
## Структура примера
```
<пример>/
├── main.tf — конфигурация провайдера
├── *.tf — ресурсы: функции, триггеры, задания
├── variables.tf — входные переменные
├── terraform.tfvars — значения переменных (не коммитится в git)
└── code/ — исходный код функций
```
@@ -0,0 +1,26 @@
# Изменено: 2026-03-14
# event-cleaner: HTTP функция для демонстрации контролируемого изменения логики.
# Работает без внешних пакетов.
import json
from datetime import datetime, timezone
def event_cleaner_handle(event_cleaner_event):
"""Принимает delete_n и возвращает подтверждение обработки."""
event_cleaner_payload = event_cleaner_event if isinstance(event_cleaner_event, dict) else {}
event_cleaner_delete_n = int(event_cleaner_payload.get("delete_n", 10))
event_cleaner_delete_n = max(1, min(event_cleaner_delete_n, 100))
event_cleaner_generated_at = datetime.now(timezone.utc).isoformat()
# Здесь intentionally имитируем очистку, чтобы показать реакцию на входные параметры.
return json.dumps(
{
"ok": True,
"accepted_delete_n": event_cleaner_delete_n,
"status": "simulated-cleanup",
"generated_at": event_cleaner_generated_at,
}
), 200, {"Content-Type": "application/json"}
@@ -0,0 +1,25 @@
# Изменено: 2026-03-14
# event-monitor: cron-функция для демонстрации расписания и управления кодом.
# Работает без внешних библиотек и возвращает диагностический JSON.
import json
import os
from datetime import datetime, timezone
EVENT_MONITOR_THRESHOLD = 50
def event_monitor_handle(event_monitor_event):
"""Отдаёт heartbeat для cron-запуска и видимой проверки после apply."""
event_monitor_rabbitmq_url = os.environ.get("RABBITMQ_URL", "not-set")
event_monitor_generated_at = datetime.now(timezone.utc).isoformat()
return json.dumps(
{
"ok": True,
"monitor": "alive",
"threshold": EVENT_MONITOR_THRESHOLD,
"rabbitmq_configured": event_monitor_rabbitmq_url != "not-set",
"generated_at": event_monitor_generated_at,
}
), 200, {"Content-Type": "application/json"}
@@ -0,0 +1,28 @@
# Изменено: 2026-03-14
# event-writer: простая HTTP функция без внешних зависимостей.
# Правка поля response_tag в коде сразу видна в ответе после terraform apply.
import json
import os
from datetime import datetime, timezone
def event_writer_handle(event_writer_event):
"""Возвращает полезный JSON-ответ для визуальной проверки выката кода."""
writer_default_message = os.environ.get("DEFAULT_MESSAGE", "writer-default")
writer_payload = event_writer_event if isinstance(event_writer_event, dict) else {}
writer_message_value = writer_payload.get("message", writer_default_message)
writer_source_name = writer_payload.get("source", "event-writer")
writer_generated_at = datetime.now(timezone.utc).isoformat()
# response_tag удобно менять для демонстрации hot-update кода через terraform apply.
writer_response_tag = "writer-v2"
return {
"ok": True,
"source": writer_source_name,
"message": writer_message_value,
"response_tag": writer_response_tag,
"generated_at": writer_generated_at,
}
@@ -0,0 +1,77 @@
# 2026-03-14
# function.tf — ресурсы managed serverless функций для демонстрации.
# Пользователь правит код в code/* и запускает terraform apply — провайдер сам пересобирает и выкатывает функции.
resource "sless_function" "event_writer" {
name = "event-writer"
runtime = "python3.11"
entrypoint = "event_writer_handler.event_writer_handle"
memory_mb = 128
timeout_sec = 20
source_dir = "${path.module}/code/event-writer"
env_vars = {
POSTGRES_DSN = var.pg_dsn
DEFAULT_MESSAGE = var.writer_message
}
}
resource "sless_trigger" "event_writer_http" {
name = "event-writer-http"
type = "http"
function = sless_function.event_writer.name
enabled = true
}
resource "sless_function" "event_monitor" {
name = "event-monitor"
runtime = "python3.11"
entrypoint = "event_monitor_handler.event_monitor_handle"
memory_mb = 128
timeout_sec = 20
source_dir = "${path.module}/code/event-monitor"
env_vars = {
POSTGRES_DSN = var.pg_dsn
RABBITMQ_URL = var.rabbitmq_url
}
}
resource "sless_trigger" "event_monitor_cron" {
name = "event-monitor-cron"
type = "cron"
function = sless_function.event_monitor.name
enabled = true
schedule = "*/1 * * * *"
}
resource "sless_function" "event_cleaner" {
name = "event-cleaner"
runtime = "python3.11"
entrypoint = "event_cleaner_handler.event_cleaner_handle"
memory_mb = 128
timeout_sec = 20
source_dir = "${path.module}/code/event-cleaner"
env_vars = {
POSTGRES_DSN = var.pg_dsn
}
}
resource "sless_trigger" "event_cleaner_http" {
name = "event-cleaner-http"
type = "http"
function = sless_function.event_cleaner.name
enabled = true
}
output "event_writer_url" {
value = sless_trigger.event_writer_http.url
}
output "event_cleaner_url" {
value = sless_trigger.event_cleaner_http.url
}
+18
View File
@@ -0,0 +1,18 @@
# 2026-03-14
# Terraform demo: managed serverless functions.
# Здесь управляем ТОЛЬКО функциями/триггерами, внешние сервисы считаем уже поднятыми.
terraform {
required_providers {
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.18"
}
}
}
provider "sless" {
endpoint = var.sless_endpoint
token = var.token
nubes_endpoint = var.nubes_endpoint
}
@@ -0,0 +1,11 @@
# 2026-03-14
# Скопируй в terraform.tfvars и подставь актуальный токен.
token = "PUT_TOKEN_HERE"
sless_endpoint = "https://sless-api.kube5s.ru"
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
pg_dsn = "postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable"
rabbitmq_url = "amqp://sless:sless123@rabbitmq.sless.svc.cluster.local:5672/"
writer_message = "writer-default-v1"
@@ -0,0 +1,38 @@
# 2026-03-14
# Переменные для demo-managed-functions.
variable "token" {
description = "JWT токен API"
type = string
sensitive = true
}
variable "sless_endpoint" {
description = "Endpoint sless API"
type = string
default = "https://sless.kube5s.ru"
}
variable "nubes_endpoint" {
description = "Nubes endpoint (нужен провайдеру)"
type = string
default = "https://deck-api-test.ngcloud.ru/api/v1"
}
variable "pg_dsn" {
description = "PostgreSQL DSN для функций"
type = string
default = "postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable"
}
variable "rabbitmq_url" {
description = "RabbitMQ URL для функций"
type = string
default = "amqp://sless:sless123@rabbitmq.sless.svc.cluster.local:5672/"
}
variable "writer_message" {
description = "Сообщение по умолчанию, которое пишет event-writer"
type = string
default = "writer-default-v1"
}
+3 -3
View File
@@ -5,13 +5,13 @@ terraform {
required_providers {
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.17"
version = "~> 0.1.18"
}
}
}
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
endpoint = "https://sless.kube5s.ru"
token = var.token
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
}
+3 -3
View File
@@ -11,14 +11,14 @@ terraform {
required_providers {
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.17"
version = "~> 0.1.18"
}
}
}
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
endpoint = "https://sless.kube5s.ru"
token = var.token
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
}
+3 -3
View File
@@ -14,14 +14,14 @@ terraform {
# Провайдер для управления serverless функциями через sless API
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.17"
version = "~> 0.1.18"
}
}
}
# sless провайдер подключается к API кластера.
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
endpoint = "https://sless.kube5s.ru"
token = var.token
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
}
+3 -4
View File
@@ -5,14 +5,13 @@ terraform {
required_providers {
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.17"
version = "~> 0.1.18"
}
}
}
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
endpoint = "https://sless.kube5s.ru"
token = var.token
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
ai_hint_level = 3
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
}
+3 -3
View File
@@ -19,13 +19,13 @@ terraform {
required_providers {
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.17"
version = "~> 0.1.18"
}
}
}
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
endpoint = "https://sless.kube5s.ru"
token = var.token
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
}
+3 -3
View File
@@ -18,13 +18,13 @@ terraform {
required_providers {
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.17"
version = "~> 0.1.18"
}
}
}
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
endpoint = "https://sless.kube5s.ru"
token = var.token
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
}
-85
View File
@@ -1,85 +0,0 @@
// internal/ai/analyzer.go
// Дата: 2026-03-12
// Оркестратор AI-анализа: читает уровень, формирует промпт,
// вызывает LLM-провайдер, форматирует вывод для пользователя.
package ai
import (
"context"
"fmt"
"strings"
)
// Analyzer — основной компонент AI-анализа.
// Создаётся один раз при старте sless-plan, используется для одного анализа.
type Analyzer struct {
provider LLMProvider
level int
}
// NewAnalyzer создаёт Analyzer из конфига.
// Возвращает ошибку если провайдер не может быть инициализирован.
func NewAnalyzer(cfg Config) (*Analyzer, error) {
if cfg.HintLevel == 0 {
// Уровень 0 — AI выключен, провайдер не нужен
return &Analyzer{level: 0}, nil
}
provider, err := NewProvider(cfg)
if err != nil {
return nil, fmt.Errorf("ошибка инициализации AI провайдера: %w", err)
}
return &Analyzer{
provider: provider,
level: cfg.HintLevel,
}, nil
}
// IsEnabled возвращает true если уровень > 0 и провайдер инициализирован.
func (a *Analyzer) IsEnabled() bool {
return a.level > 0 && a.provider != nil
}
// Analyze отправляет вывод terraform plan в LLM и возвращает отформатированный результат.
// planText — текстовый вывод terraform plan.
func (a *Analyzer) Analyze(ctx context.Context, planText string) (string, error) {
if !a.IsEnabled() {
return "", nil
}
// Ограничиваем размер payload — большие планы обрезаем
// чтобы не превысить лимит токенов LLM и не отправлять лишнее
planText = truncatePlan(planText, 8000)
prompt := BuildPrompt(a.level, planText)
result, err := a.provider.Analyze(ctx, prompt)
if err != nil {
return "", fmt.Errorf("AI анализ не удался: %w", err)
}
return FormatResult(a.level, a.provider.Name(), result), nil
}
// FormatResult оборачивает ответ LLM в читаемый блок для вывода в терминал.
func FormatResult(level int, providerName, result string) string {
separator := strings.Repeat("─", 60)
header := fmt.Sprintf("AI Analysis (level %d: %s) via %s", level, LevelName(level), providerName)
return fmt.Sprintf("\n%s\n %s\n%s\n%s\n%s\n",
separator,
header,
separator,
result,
separator,
)
}
// truncatePlan обрезает текст плана до maxChars символов с пометкой об обрезке.
// Нужно чтобы не превышать лимиты токенов LLM и не отправлять лишние данные.
func truncatePlan(text string, maxChars int) string {
if len(text) <= maxChars {
return text
}
return text[:maxChars] + fmt.Sprintf("\n\n[... обрезано, показаны первые %d символов из %d ...]", maxChars, len(text))
}
-40
View File
@@ -1,40 +0,0 @@
// internal/ai/cloud_llm.go
// Дата: 2026-03-12
// Заглушка для будущего облачного LLM.
// Когда облако предоставит свой LLM — реализовать этот файл
// и переключить AI_PROVIDER=cloud. Остальной код не меняется.
package ai
import (
"context"
"fmt"
)
// CloudLLMProvider — будущая реализация LLMProvider для облачного LLM.
// Пока возвращает ошибку "не реализован".
type CloudLLMProvider struct {
endpoint string
token string
}
// NewCloudLLMProvider создаёт заглушку провайдера облачного LLM.
func NewCloudLLMProvider(endpoint, token string) (*CloudLLMProvider, error) {
if endpoint == "" {
return nil, fmt.Errorf("CLOUD_LLM_ENDPOINT не задан")
}
return &CloudLLMProvider{
endpoint: endpoint,
token: token,
}, nil
}
func (c *CloudLLMProvider) Name() string {
return fmt.Sprintf("cloud-llm/%s", c.endpoint)
}
// Analyze — TODO: реализовать когда облако предоставит LLM API.
// Документация по API будет в doc/api/cloud-llm.md.
func (c *CloudLLMProvider) Analyze(_ context.Context, _ string) (string, error) {
return "", fmt.Errorf("cloud LLM провайдер ещё не реализован (endpoint: %s)", c.endpoint)
}
-125
View File
@@ -1,125 +0,0 @@
// internal/ai/google_gemini.go
// Дата: 2026-03-12
// Реализация LLMProvider для Google Gemini API.
// Использует REST API напрямую через net/http — без внешних SDK,
// чтобы не нарушать совместимость существующего go.mod.
package ai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const geminiBaseURL = "https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent"
// GeminiProvider реализует LLMProvider через Google Gemini REST API.
type GeminiProvider struct {
apiKey string
model string
httpClient *http.Client
}
// NewGeminiProvider создаёт провайдера для Google Gemini.
// apiKey — значение env GOOGLE_AI_API_KEY.
func NewGeminiProvider(apiKey, model string) (*GeminiProvider, error) {
if apiKey == "" {
return nil, fmt.Errorf("GOOGLE_AI_API_KEY не задан")
}
return &GeminiProvider{
apiKey: apiKey,
model: model,
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
}, nil
}
func (g *GeminiProvider) Name() string {
return fmt.Sprintf("google-gemini/%s", g.model)
}
// geminiRequest — структура запроса к Gemini API.
type geminiRequest struct {
Contents []geminiContent `json:"contents"`
}
type geminiContent struct {
Parts []geminiPart `json:"parts"`
}
type geminiPart struct {
Text string `json:"text"`
}
// geminiResponse — структура ответа от Gemini API.
type geminiResponse struct {
Candidates []struct {
Content struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"content"`
} `json:"candidates"`
Error *struct {
Message string `json:"message"`
Code int `json:"code"`
} `json:"error,omitempty"`
}
// Analyze отправляет промпт в Gemini и возвращает текстовый ответ.
func (g *GeminiProvider) Analyze(ctx context.Context, prompt string) (string, error) {
reqBody := geminiRequest{
Contents: []geminiContent{
{Parts: []geminiPart{{Text: prompt}}},
},
}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("ошибка маршалинга запроса: %w", err)
}
url := fmt.Sprintf(geminiBaseURL+"?key=%s", g.model, g.apiKey)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
if err != nil {
return "", fmt.Errorf("ошибка создания HTTP запроса: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := g.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("ошибка HTTP запроса к Gemini: %w", err)
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("ошибка чтения ответа: %w", err)
}
var gemResp geminiResponse
if err := json.Unmarshal(respBytes, &gemResp); err != nil {
return "", fmt.Errorf("ошибка парсинга ответа Gemini: %w", err)
}
// Проверяем HTTP-ошибку и ошибку в теле ответа
if resp.StatusCode != http.StatusOK {
msg := fmt.Sprintf("HTTP %d", resp.StatusCode)
if gemResp.Error != nil {
msg = gemResp.Error.Message
}
return "", fmt.Errorf("Gemini API ошибка: %s", msg)
}
if len(gemResp.Candidates) == 0 || len(gemResp.Candidates[0].Content.Parts) == 0 {
return "", fmt.Errorf("Gemini вернул пустой ответ")
}
return gemResp.Candidates[0].Content.Parts[0].Text, nil
}
-125
View File
@@ -1,125 +0,0 @@
// internal/ai/openai_compat.go
// Дата: 2026-03-12
// OpenAI-совместимый провайдер — работает с Groq, OpenRouter, OpenAI и любым
// другим сервисом реализующим /v1/chat/completions API.
// Нет геоблока в отличие от Google Gemini.
package ai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// OpenAICompatProvider реализует LLMProvider через OpenAI-совместимый API.
// Groq endpoint: https://api.groq.com/openai/v1/chat/completions
type OpenAICompatProvider struct {
endpoint string
apiKey string
model string
httpClient *http.Client
}
// NewOpenAICompatProvider создаёт провайдера для любого OpenAI-совместимого API.
// endpoint — полный URL до /v1/chat/completions
// apiKey — Bearer токен (GROQ_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY)
// model — название модели (например "llama-3.3-70b-versatile" для Groq)
func NewOpenAICompatProvider(endpoint, apiKey, model string) (*OpenAICompatProvider, error) {
if apiKey == "" {
return nil, fmt.Errorf("GROQ_API_KEY не задан")
}
if endpoint == "" {
endpoint = "https://api.groq.com/openai/v1/chat/completions"
}
if model == "" {
model = "llama-3.3-70b-versatile"
}
return &OpenAICompatProvider{
endpoint: endpoint,
apiKey: apiKey,
model: model,
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
}, nil
}
func (p *OpenAICompatProvider) Name() string {
return fmt.Sprintf("groq/%s", p.model)
}
// openAIRequest — тело запроса к /v1/chat/completions
type openAIRequest struct {
Model string `json:"model"`
Messages []openAIMessage `json:"messages"`
}
type openAIMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
// openAIResponse — тело ответа от /v1/chat/completions
type openAIResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error,omitempty"`
}
// Analyze отправляет промпт в OpenAI-совместимый API и возвращает ответ.
func (p *OpenAICompatProvider) Analyze(ctx context.Context, prompt string) (string, error) {
reqBody := openAIRequest{
Model: p.model,
Messages: []openAIMessage{
{Role: "user", Content: prompt},
},
}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal error: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.endpoint, bytes.NewReader(bodyBytes))
if err != nil {
return "", fmt.Errorf("create request error: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+p.apiKey)
resp, err := p.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("http error: %w", err)
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read response error: %w", err)
}
var parsed openAIResponse
if err := json.Unmarshal(respBytes, &parsed); err != nil {
return "", fmt.Errorf("parse response error: %w", err)
}
if parsed.Error != nil {
return "", fmt.Errorf("API ошибка: %s", parsed.Error.Message)
}
if len(parsed.Choices) == 0 || parsed.Choices[0].Message.Content == "" {
return "", fmt.Errorf("пустой ответ от API")
}
return parsed.Choices[0].Message.Content, nil
}
-76
View File
@@ -1,76 +0,0 @@
// internal/ai/prompts.go
// Дата: 2026-03-12
// Шаблоны system-промптов для каждого уровня анализа (1–5).
// Уровень 0 — AI не вызывается вообще (проверка в analyzer.go).
package ai
import "fmt"
// systemPrompts — system prompt для каждого уровня.
// Каждый уровень строго ограничен своей областью — не расширять без явного указания.
var systemPrompts = map[int]string{
1: `Ты — анализатор Terraform конфигурации. Твоя задача: ТОЛЬКО синтаксический анализ.
Найди синтаксические ошибки в HCL: опечатки в ключевых словах, незакрытые блоки, неверные типы значений, невалидные атрибуты.
НЕ давай рекомендаций по улучшению, НЕ анализируй логику, НЕ комментируй архитектуру.
Формат ответа: список найденных ошибок или "✓ Синтаксических ошибок не обнаружено."`,
2: `Ты — анализатор Terraform конфигурации. Твоя задача: синтаксис + базовые проблемы.
1. Синтаксические ошибки в HCL.
2. Базовые проблемы: неиспользуемые переменные/ресурсы, пустые обязательные значения, дублирующиеся ресурсы.
НЕ анализируй зависимости, архитектуру, безопасность.
Формат: список проблем с пометкой [SYNTAX] или [BASIC], или "✓ Проблем не обнаружено."`,
3: `Ты — анализатор Terraform конфигурации. Твоя задача: синтаксис + базовые проблемы + зависимости.
1. Синтаксические ошибки.
2. Базовые проблемы (неиспользуемые ресурсы, пустые значения).
3. Анализ зависимостей: потенциальные race conditions при apply/destroy, неявные зависимости, проблемы с порядком создания ресурсов.
НЕ давай рекомендаций по безопасности или рефакторингу.
Формат: список проблем с пометкой [SYNTAX], [BASIC] или [DEPS], или "✓ Проблем не обнаружено."`,
4: `Ты — анализатор Terraform конфигурации. Твоя задача: полный анализ + безопасность.
1. Синтаксические ошибки.
2. Базовые проблемы.
3. Зависимости и порядок операций.
4. Безопасность и best practices: открытые порты (0.0.0.0/0), избыточные права доступа, незашифрованные ресурсы, жёстко заданные секреты.
Формат: список проблем с пометкой [SYNTAX], [BASIC], [DEPS] или [SECURITY], или "✓ Проблем не обнаружено."`,
5: `Ты — старший DevOps-инженер, проводишь полный аудит Terraform конфигурации.
Анализируй:
1. Синтаксические ошибки.
2. Базовые проблемы.
3. Зависимости и порядок операций.
4. Безопасность и best practices.
5. Оптимизация: возможности для рефакторинга, использование модулей, устаревшие паттерны, альтернативные подходы.
Давай конкретные рекомендации с примерами кода где уместно.
Формат: список проблем и рекомендаций с пометкой [SYNTAX], [BASIC], [DEPS], [SECURITY] или [OPT].`,
}
// levelNames — человекочитаемые названия уровней для вывода.
var levelNames = map[int]string{
0: "OFF",
1: "SYNTAX",
2: "BASIC",
3: "MODERATE",
4: "DETAILED",
5: "FULL",
}
// BuildPrompt формирует финальный промпт для отправки в LLM.
// planText — вывод terraform plan (текст или JSON).
func BuildPrompt(level int, planText string) string {
sysPrompt, ok := systemPrompts[level]
if !ok {
sysPrompt = systemPrompts[1]
}
return fmt.Sprintf("%s\n\nTerraform plan output:\n```\n%s\n```", sysPrompt, planText)
}
// LevelName возвращает название уровня для вывода пользователю.
func LevelName(level int) string {
name, ok := levelNames[level]
if !ok {
return "UNKNOWN"
}
return name
}
-55
View File
@@ -1,55 +0,0 @@
// internal/ai/provider.go
// Дата: 2026-03-12 (обновлён 2026-03-12: добавлен Groq / OpenAI-compat)
// Интерфейс LLMProvider — абстракция над любым LLM-бэкендом.
// Реализации: Google Gemini, Groq (OpenAI-compat), облачный LLM (stub).
package ai
import "context"
// LLMProvider — единственный интерфейс для всех LLM-провайдеров.
// Менять провайдер = менять одну env-переменную AI_PROVIDER.
type LLMProvider interface {
// Analyze отправляет промпт в LLM и возвращает текстовый ответ.
Analyze(ctx context.Context, prompt string) (string, error)
// Name возвращает имя провайдера для логирования.
Name() string
}
// Config — конфигурация анализатора, читается из env-переменных.
type Config struct {
// Provider: "groq" (по умолчанию), "google", "cloud"
Provider string
// HintLevel: 0–5, если 0 — AI не вызывается
HintLevel int
// Groq / OpenAI-совместимые провайдеры
GroqAPIKey string
GroqModel string // по умолчанию "llama-3.3-70b-versatile"
GroqEndpoint string // по умолчанию https://api.groq.com/openai/v1/chat/completions
// Google Gemini (геоблок в РФ)
GoogleAPIKey string
GoogleModel string // по умолчанию "gemini-2.0-flash"
// Будущий облачный LLM
CloudEndpoint string
CloudToken string
}
// NewProvider — фабрика провайдеров. Выбор по Config.Provider.
// При добавлении нового провайдера — только сюда добавить case.
func NewProvider(cfg Config) (LLMProvider, error) {
switch cfg.Provider {
case "groq":
return NewOpenAICompatProvider(cfg.GroqEndpoint, cfg.GroqAPIKey, cfg.GroqModel)
case "google":
model := cfg.GoogleModel
if model == "" {
model = "gemini-2.0-flash"
}
return NewGeminiProvider(cfg.GoogleAPIKey, model)
case "cloud":
return NewCloudLLMProvider(cfg.CloudEndpoint, cfg.CloudToken)
default:
// По умолчанию groq — работает из РФ без геоблоков
return NewOpenAICompatProvider(cfg.GroqEndpoint, cfg.GroqAPIKey, cfg.GroqModel)
}
}
+41 -20
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-03-11
// Изменено: 2026-03-18 (добавлены created_at, last_built_at в functionResponse и fnToResponse)
// functions.go — CRUD handlers для Function CRD.
// Принимает JSON, создаёт/обновляет/удаляет k8s ресурсы Function.
// Namespace берётся из URL: /v1/namespaces/{namespace}/functions/{name}
@@ -30,23 +30,29 @@ type functionRequest struct {
// functionResponse — ответ при чтении функции.
type functionResponse struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
Runtime string `json:"runtime"`
Entrypoint string `json:"entrypoint"`
MemoryMB int32 `json:"memory_mb"`
TimeoutSec int32 `json:"timeout_sec"`
Env map[string]string `json:"env_vars"`
S3Bucket string `json:"s3_bucket"`
S3Key string `json:"s3_key"`
Phase slessv1alpha1.FunctionPhase `json:"phase"`
ImageRef string `json:"image_ref"`
Message string `json:"message,omitempty"`
Name string `json:"name"`
Namespace string `json:"namespace"`
Runtime string `json:"runtime"`
Entrypoint string `json:"entrypoint"`
MemoryMB int32 `json:"memory_mb"`
TimeoutSec int32 `json:"timeout_sec"`
Env map[string]string `json:"env_vars"`
S3Bucket string `json:"s3_bucket"`
S3Key string `json:"s3_key"`
Phase slessv1alpha1.FunctionPhase `json:"phase"`
ImageRef string `json:"image_ref"`
Message string `json:"message,omitempty"`
// CreatedAt — время создания CRD объекта (metadata.creationTimestamp).
// Пустое значение = "0001-01-01T00:00:00Z" сериализуется в "", опускаем через omitempty.
CreatedAt string `json:"created_at,omitempty"`
// LastBuiltAt — время последней успешной сборки образа (status.lastBuiltAt).
// nil если сборки ещё не было.
LastBuiltAt string `json:"last_built_at,omitempty"`
}
// fnToResponse конвертирует CRD в ответ API.
func fnToResponse(fn *slessv1alpha1.Function) functionResponse {
return functionResponse{
resp := functionResponse{
Name: fn.Name,
Namespace: fn.Namespace,
Runtime: fn.Spec.Runtime,
@@ -60,6 +66,14 @@ func fnToResponse(fn *slessv1alpha1.Function) functionResponse {
ImageRef: fn.Status.ImageRef,
Message: fn.Status.Message,
}
// creationTimestamp — всегда заполнен k8s, но zero value опускаем.
if !fn.CreationTimestamp.IsZero() {
resp.CreatedAt = fn.CreationTimestamp.UTC().Format("2006-01-02 15:04:05 UTC")
}
if fn.Status.LastBuiltAt != nil && !fn.Status.LastBuiltAt.IsZero() {
resp.LastBuiltAt = fn.Status.LastBuiltAt.UTC().Format("2006-01-02 15:04:05 UTC")
}
return resp
}
// ListFunctions — GET /v1/namespaces/{namespace}/functions
@@ -115,13 +129,20 @@ func (h *Handler) CreateFunction(w http.ResponseWriter, r *http.Request) {
}
if err := h.K8s.Create(r.Context(), fn); err != nil {
if errors.IsAlreadyExists(err) {
// Если существующая функция в статусе Failed (build провалился, terraform не
// добавил её в state) — удаляем её и пересоздаём, иначе клиент получит 409 навсегда.
// IsAlreadyExists может прийти из кеша controller-runtime (split-brain):
// объект удалён из etcd, но кеш informer ещё южив. Делаем uncached Get:
// если реально NotFound — кеш устарел, пересоздаём.
// если существует и фаза Failed — тоже пересоздаём (build провалился, терраформ не добавил в state).
// если существует и фаза Ready/Building — возвращаем 409 (функция реально есть).
existing := &slessv1alpha1.Function{}
if getErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing); getErr == nil &&
existing.Status.Phase == slessv1alpha1.FunctionPhaseFailed {
_ = h.K8s.Delete(r.Context(), existing)
// Создаём заново с теми же параметрами
getErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing)
shouldRecreate := errors.IsNotFound(getErr) ||
(getErr == nil && existing.Status.Phase == slessv1alpha1.FunctionPhaseFailed)
if shouldRecreate {
if getErr == nil {
_ = h.K8s.Delete(r.Context(), existing)
}
// Сбрасываем ResourceVersion — при split-brain etcd считает объект новым
fn.ResourceVersion = ""
if createErr := h.K8s.Create(r.Context(), fn); createErr != nil {
writeJSON(w, http.StatusInternalServerError, errResp(createErr.Error()))
+4 -1
View File
@@ -66,10 +66,13 @@ func (h *Handler) InvokeFunction(w http.ResponseWriter, r *http.Request) {
return
}
// Пробрасываем Content-Type если есть
// Пробрасываем Content-Type и Content-Length если есть.
// Content-Length обязателен: Python BaseHTTPRequestHandler читает тело
// ровно столько байт, сколько указано в заголовке; без него body = пусто.
if ct := r.Header.Get("Content-Type"); ct != "" {
proxyReq.Header.Set("Content-Type", ct)
}
proxyReq.ContentLength = r.ContentLength
resp, err := httpClient.Do(proxyReq)
if err != nil {
+119
View File
@@ -0,0 +1,119 @@
// Создано: 2026-04-25
// source.go — handler для GET /v1/namespaces/{namespace}/functions/{name}/source
// Возвращает список файлов исходного кода функции из tar.gz контекста сборки (S3).
// Dockerfile исключается — он сгенерирован builder-ом, не является кодом пользователя.
// Если код ещё не загружен (S3Key пустой) — возвращает пустой список.
package handler
import (
"archive/tar"
"compress/gzip"
"encoding/base64"
"fmt"
"io"
"net/http"
"strings"
"k8s.io/apimachinery/pkg/api/errors"
"sigs.k8s.io/controller-runtime/pkg/client"
slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1"
)
// sourceFileEntry — один файл из исходного кода функции.
type sourceFileEntry struct {
Name string `json:"name"`
Content string `json:"content"` // UTF-8 текст; бинарные файлы — base64 encoded
Binary bool `json:"binary"` // true если файл бинарный (base64 в content)
}
// GetSource — GET /v1/namespaces/{namespace}/functions/{name}/source
// Скачивает tar.gz контекст сборки из S3 и возвращает пользовательские файлы (без Dockerfile).
func (h *Handler) GetSource(w http.ResponseWriter, r *http.Request) {
ns := namespace(r)
name := pathVar(r, "name")
fn := &slessv1alpha1.Function{}
if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, fn); err != nil {
if errors.IsNotFound(err) {
writeJSON(w, http.StatusNotFound, errResp("function not found"))
return
}
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
// Код ещё не был загружен — возвращаем пустой список без ошибки
if fn.Spec.S3Key == "" {
writeJSON(w, http.StatusOK, []sourceFileEntry{})
return
}
rc, err := h.S3.Download(r.Context(), fn.Spec.S3Key)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errResp("download from S3: "+err.Error()))
return
}
defer rc.Close()
files, err := extractSourceFilesFromTarGz(rc)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errResp("extract source: "+err.Error()))
return
}
writeJSON(w, http.StatusOK, files)
}
// extractSourceFilesFromTarGz читает tar.gz и возвращает пользовательские файлы.
// Dockerfile пропускается — он генерируется builder-ом автоматически.
// Бинарные файлы (содержат null bytes) возвращаются в base64 с binary=true.
func extractSourceFilesFromTarGz(r io.Reader) ([]sourceFileEntry, error) {
gr, err := gzip.NewReader(r)
if err != nil {
return nil, fmt.Errorf("gzip: %w", err)
}
defer gr.Close()
tr := tar.NewReader(gr)
var result []sourceFileEntry
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("tar next: %w", err)
}
// Dockerfile сгенерирован builder-ом — не показываем пользователю
if hdr.Name == "Dockerfile" || hdr.FileInfo().IsDir() {
continue
}
data, err := io.ReadAll(tr)
if err != nil {
return nil, fmt.Errorf("read %s: %w", hdr.Name, err)
}
// Бинарные файлы — base64, текстовые — как есть
content := string(data)
isBinary := strings.ContainsRune(content, '\x00')
if isBinary {
content = base64.StdEncoding.EncodeToString(data)
}
result = append(result, sourceFileEntry{
Name: hdr.Name,
Content: content,
Binary: isBinary,
})
}
if result == nil {
result = []sourceFileEntry{}
}
return result, nil
}
+4 -1
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-03-08
// Изменено: 2026-04-25
// router.go — регистрация всех REST-маршрутов через gorilla/mux.
// Все маршруты защищены Bearer-токеном (middleware.Auth).
// Маршруты сгруппированы по /v1/namespaces/{namespace}/...
@@ -45,6 +45,9 @@ func NewRouter(h *handler.Handler, log *slog.Logger) http.Handler {
// Upload code — принимает zip, генерирует Dockerfile, кладёт tar.gz в S3, запускает сборку
v1.HandleFunc("/namespaces/{namespace}/functions/{name}/upload", h.UploadCode).Methods(http.MethodPost)
// Source code — возвращает файлы из tar.gz контекста сборки (без Dockerfile)
v1.HandleFunc("/namespaces/{namespace}/functions/{name}/source", h.GetSource).Methods(http.MethodGet)
// Triggers CRUD
v1.HandleFunc("/namespaces/{namespace}/triggers", h.ListTriggers).Methods(http.MethodGet)
v1.HandleFunc("/namespaces/{namespace}/triggers", h.CreateTrigger).Methods(http.MethodPost)
+1 -1
View File
@@ -125,7 +125,7 @@ func (b *Builder) Build(ctx context.Context, namespace, funcName, s3Key string)
Args: []string{
"--context=" + s3ContextURL,
"--destination=" + imageRef,
"--cache=true", // кешируем слои для ускорения повторных сборок
"--no-cache", // без кэша — гарантирует что COPY берёт свежий код из S3
},
Env: []corev1.EnvVar{
{Name: "AWS_ACCESS_KEY_ID", Value: b.s3AccessKey},
+1 -1
View File
@@ -66,7 +66,7 @@ func PrepareContext(zipData []byte, runtime string) (*bytes.Buffer, error) {
func runtimeBaseImage(runtime string) (string, error) {
switch runtime {
case "python3.11":
return "naeel/sless-runtime-python3.11:v0.1.2", nil
return "naeel/sless-runtime-python3.11:v0.1.4", nil
case "nodejs20":
return "naeel/sless-runtime-nodejs20:v0.1.2", nil
case "go1.23":
+3 -1
View File
@@ -121,8 +121,10 @@ func main() {
// Builder использует k8s client из manager'а
// harborClient — nil если HARBOR_USER/HARBOR_PASS не заданы (EnsureProject пропускается).
var harborClient *harbor.Client
var harborProjecter builder.Projecter
if cfg.HarborUser != "" && cfg.HarborPass != "" {
harborClient = harbor.New(cfg.RegistryHost, cfg.HarborUser, cfg.HarborPass)
harborProjecter = harborClient
log.Info("harbor client configured", "host", cfg.RegistryHost)
} else {
log.Info("harbor client disabled (HARBOR_USER/HARBOR_PASS not set)")
@@ -137,7 +139,7 @@ func main() {
S3SecretKey: cfg.S3SecretKey,
S3Bucket: cfg.S3Bucket,
Namespace: "sless",
HarborClient: harborClient,
HarborClient: harborProjecter,
})
if err = (&controllers.FunctionReconciler{
+14 -3
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# Изменено: 2026-03-11
# Изменено: 2026-03-18 (string return → text/plain без json.dumps)
# HTTP-обёртка для serverless функций на Python 3.11.
# Загружает модуль из SLESS_ENTRYPOINT или handler.py по умолчанию.
# Формат SLESS_ENTRYPOINT: "module_name.func_name" (например: handler.handle)
@@ -56,6 +56,8 @@ class FunctionHandler(BaseHTTPRequestHandler):
event['_path'] = parsed.path
event['_query'] = query
event['_method'] = self.command
# Accept заголовок — позволяет функции различать браузер и curl/API.
event['_accept'] = self.headers.get('Accept', '')
return event
def do_GET(self):
@@ -110,9 +112,18 @@ class FunctionHandler(BaseHTTPRequestHandler):
self.wfile.write(body)
def _respond(self, status, data):
body = json.dumps(data).encode("utf-8")
# Если функция вернула строку:
# начинается с '<' → HTML (text/html) — для страниц с формами.
# иначе → text/plain — для человекочитаемого текста.
# Позволяет функциям возвращать HTML напрямую без изменений runtime-образа.
if isinstance(data, str):
body = data.encode("utf-8")
ctype = "text/html; charset=utf-8" if data.lstrip().startswith("<") else "text/plain; charset=utf-8"
else:
body = json.dumps(data).encode("utf-8")
ctype = "application/json"
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
+14
View File
@@ -0,0 +1,14 @@
# 2026-04-25 (добавлен index.html — встраивается через go:embed)
# Dockerfile для sless-funcs-service.
# Многоэтапная сборка: Go → alpine (минимальный образ).
FROM golang:1.23-alpine AS builder
WORKDIR /build
COPY go.mod main.go index.html ./
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o funcs-service .
FROM alpine:3.20
RUN apk add --no-cache ca-certificates
COPY --from=builder /build/funcs-service /funcs-service
EXPOSE 8090
ENTRYPOINT ["/funcs-service"]
+107
View File
@@ -0,0 +1,107 @@
# 2026-03-18
# funcs-service.yaml — глобальный сервис листинга функций для всех пользователей.
# Развёртывается ОДИН РАЗ в namespace sless рядом с оператором.
# Доступен по: https://sless.kube5s.ru/funcs (с Bearer токеном пользователя)
#
# Обновить образ и применить:
# docker push naeel/sless-funcs-service:v0.1.0
# kubectl apply -f deployments/k8s/funcs-service.yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: sless-funcs-service
namespace: sless
labels:
app: sless-funcs-service
spec:
replicas: 1
selector:
matchLabels:
app: sless-funcs-service
template:
metadata:
labels:
app: sless-funcs-service
spec:
containers:
- name: funcs
image: naeel/sless-funcs-service:v0.1.3
ports:
- containerPort: 8090
env:
- name: SLESS_OPERATOR_URL
value: "http://sless-operator.sless.svc.cluster.local:9090"
- name: SLESS_EXTERNAL_URL
value: "https://sless.kube5s.ru"
# Системные функции, скрытые из листинга
- name: SLESS_EXCLUDE
value: "event-writer,event-monitor,event-cleaner"
# Токен сервиса задаётся через kubectl set env или Secret — не хранится в репо
# kubectl set env deployment/sless-funcs-service -n sless SLESS_SERVICE_TOKEN="$(cat secrets/test.token)"
- name: PORT
value: "8090"
livenessProbe:
httpGet:
path: /health
port: 8090
initialDelaySeconds: 5
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 8090
initialDelaySeconds: 3
periodSeconds: 10
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
cpu: 100m
memory: 64Mi
---
apiVersion: v1
kind: Service
metadata:
name: sless-funcs-service
namespace: sless
spec:
selector:
app: sless-funcs-service
ports:
- port: 8090
targetPort: 8090
---
# Отдельный Ingress для /funcs — nginx выбирает более специфичный путь перед /
# Без rewrite: сервис сам обрабатывает /funcs path
# TLS-сертификат sless-operator-tls уже управляется cert-manager через ingress оператора;
# здесь только ссылаемся на существующий секрет без аннотации cert-manager.io/cluster-issuer.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: sless-funcs-ingress
namespace: sless
annotations:
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
rules:
- host: sless.kube5s.ru
http:
paths:
- path: /funcs
pathType: Prefix
backend:
service:
name: sless-funcs-service
port:
number: 8090
tls:
- hosts:
- sless.kube5s.ru
secretName: sless-operator-tls
+3
View File
@@ -0,0 +1,3 @@
module sless/funcs-service
go 1.23
+492
View File
@@ -0,0 +1,492 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>sless console</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
background: #0d1117;
color: #e6edf3;
min-height: 100vh;
}
a {
color: inherit;
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #161b22;
}
::-webkit-scrollbar-thumb {
background: #30363d;
border-radius: 4px;
}
header {
padding: 14px 24px;
border-bottom: 1px solid #30363d;
display: flex;
align-items: center;
gap: 12px;
background: #161b22;
position: sticky;
top: 0;
z-index: 10;
}
.logo {
font-weight: 700;
font-size: 1.1rem;
color: #58a6ff;
letter-spacing: -0.3px;
}
.logo-slash {
color: #484f58;
margin: 0 4px;
}
.ns-badge {
background: #1c2128;
border: 1px solid #30363d;
padding: 3px 12px;
border-radius: 20px;
font-family: monospace;
font-size: 0.82rem;
color: #8b949e;
}
.refresh-btn {
margin-left: auto;
background: #21262d;
border: 1px solid #30363d;
color: #c9d1d9;
padding: 6px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 0.82rem;
transition: background 0.1s;
}
.refresh-btn:hover {
background: #30363d;
}
main {
padding: 24px;
max-width: 960px;
margin: 0 auto;
}
.summary {
color: #8b949e;
font-size: 0.82rem;
margin-bottom: 18px;
}
.fn-card {
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
margin-bottom: 10px;
overflow: hidden;
transition: border-color 0.15s;
}
.fn-card:hover {
border-color: #484f58;
}
.fn-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
cursor: pointer;
user-select: none;
flex-wrap: wrap;
row-gap: 6px;
}
.fn-header:hover {
background: rgba(255, 255, 255, 0.03);
}
.fn-name {
font-weight: 600;
font-size: 0.92rem;
color: #e6edf3;
}
.badge {
display: inline-block;
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 12px;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.badge-runtime {
background: #21262d;
border: 1px solid #30363d;
color: #8b949e;
}
.badge-Ready {
background: #1a4731;
border: 1px solid #1f6132;
color: #3fb950;
}
.badge-Building {
background: #382a0a;
border: 1px solid #5a4212;
color: #d29922;
}
.badge-Pending {
background: #21262d;
border: 1px solid #30363d;
color: #8b949e;
}
.badge-Failed {
background: #3d1a1a;
border: 1px solid #6b1a1a;
color: #f85149;
}
.fn-url {
font-size: 0.76rem;
color: #58a6ff;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 280px;
flex-shrink: 1;
}
.fn-url:hover {
text-decoration: underline;
}
.fn-cron {
font-size: 0.76rem;
color: #8b949e;
font-family: monospace;
}
.fn-msg {
font-size: 0.73rem;
color: #f85149;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.spacer {
flex: 1 0 0;
min-width: 4px;
}
.trigger-btn {
flex-shrink: 0;
padding: 4px 12px;
border-radius: 6px;
border: 1px solid transparent;
cursor: pointer;
font-size: 0.76rem;
font-weight: 500;
line-height: 1.5;
transition: opacity 0.1s, background 0.1s;
}
.trigger-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.trigger-btn.state-on {
background: #21262d;
border-color: #1f6132;
color: #3fb950;
}
.trigger-btn.state-on:hover:not(:disabled) {
background: #1a4731;
}
.trigger-btn.state-off {
background: #21262d;
border-color: #6b1a1a;
color: #f85149;
}
.trigger-btn.state-off:hover:not(:disabled) {
background: #3d1a1a;
}
.expand-icon {
color: #8b949e;
flex-shrink: 0;
font-size: 0.65rem;
transition: transform 0.18s;
}
.fn-body {
border-top: 1px solid #30363d;
}
.src-msg {
padding: 14px 16px;
color: #8b949e;
font-size: 0.85rem;
font-style: italic;
}
.file-block {
border-bottom: 1px solid #21262d;
}
.file-block:last-child {
border-bottom: none;
}
.file-header {
padding: 6px 16px;
background: #0d1117;
font-family: monospace;
font-size: 0.76rem;
color: #8b949e;
border-bottom: 1px solid #21262d;
}
.file-block pre {
margin: 0;
}
.file-block pre code.hljs {
border-radius: 0 !important;
font-size: 0.78rem !important;
padding: 14px 16px !important;
line-height: 1.5 !important;
}
.empty-state {
text-align: center;
padding: 64px 24px;
color: #484f58;
font-size: 0.9rem;
}
</style>
</head>
<body>
<header>
<span class="logo">sless</span>
<span class="logo-slash">/</span>
<span class="ns-badge" id="ns-label">…</span>
<button class="refresh-btn" onclick="location.reload()">↻ обновить</button>
</header>
<main>
<p class="summary" id="summary"></p>
<div id="fn-list"></div>
</main>
<script id="fndata" type="application/json">__PAGE_DATA__</script>
<script>
(function () {
'use strict';
var raw = document.getElementById('fndata').textContent;
var data = JSON.parse(raw);
var ns = data.namespace;
var extURL = data.externalURL || '';
var fns = data.functions || [];
var byFn = data.triggersByFn || {};
document.getElementById('ns-label').textContent = ns;
document.getElementById('summary').textContent = 'Функций: ' + fns.length;
var list = document.getElementById('fn-list');
if (fns.length === 0) {
var empty = $el('div', 'empty-state');
empty.textContent = 'Нет функций в этом namespace';
list.appendChild(empty);
}
for (var i = 0; i < fns.length; i++) {
list.appendChild(buildCard(fns[i], byFn[fns[i].name] || []));
}
function buildCard(fn, triggers) {
var card = $el('div', 'fn-card');
var hdr = $el('div', 'fn-header');
var body = $el('div', 'fn-body');
body.style.display = 'none';
$txt(hdr, 'span', 'fn-name', fn.name);
$txt(hdr, 'span', 'badge badge-runtime', fn.runtime);
$txt(hdr, 'span', 'badge badge-' + fn.phase, fn.phase);
for (var t = 0; t < triggers.length; t++) {
var tr = triggers[t];
if (tr.type === 'http') {
var url = extURL ? extURL + '/fn/' + ns + '/' + fn.name : (tr.url || '');
if (url) {
var a = document.createElement('a');
a.className = 'fn-url';
a.href = url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.textContent = url;
a.addEventListener('click', function (e) { e.stopPropagation(); });
hdr.appendChild(a);
}
hdr.appendChild(makeTriggerBtn(tr, ns));
} else if (tr.type === 'cron') {
$txt(hdr, 'span', 'fn-cron', 'cron: ' + tr.schedule);
hdr.appendChild(makeTriggerBtn(tr, ns));
}
}
if (fn.message) $txt(hdr, 'span', 'fn-msg', fn.message);
hdr.appendChild($el('span', 'spacer'));
var icon = $txt(hdr, 'span', 'expand-icon', '▶');
var loaded = false;
hdr.addEventListener('click', function () {
if (body.style.display === 'none') {
body.style.display = 'block';
icon.style.transform = 'rotate(90deg)';
if (!loaded) {
loaded = true;
loadSource(body, ns, fn.name);
}
} else {
body.style.display = 'none';
icon.style.transform = '';
}
});
card.appendChild(hdr);
card.appendChild(body);
return card;
}
function makeTriggerBtn(tr, ns) {
var btn = $el('button', 'trigger-btn ' + (tr.enabled ? 'state-on' : 'state-off'));
btn.textContent = tr.enabled ? '■ стоп' : '▶ запуск';
btn.title = tr.name;
btn.addEventListener('click', function (e) {
e.stopPropagation();
btn.disabled = true;
var newVal = !tr.enabled;
fetch('/funcs/' + ns + '/triggers/' + tr.name, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: newVal })
}).then(function (r) {
if (r.ok) {
tr.enabled = newVal;
btn.className = 'trigger-btn ' + (newVal ? 'state-on' : 'state-off');
btn.textContent = newVal ? '■ стоп' : '▶ запуск';
} else {
return r.text().then(function (t) { alert('Ошибка: ' + t); });
}
}).catch(function (err) {
alert('Сетевая ошибка: ' + err);
}).finally(function () {
btn.disabled = false;
});
});
return btn;
}
function loadSource(body, ns, fnName) {
var msg = $el('div', 'src-msg');
msg.textContent = 'Загрузка кода…';
body.appendChild(msg);
fetch('/funcs/' + ns + '/source/' + fnName)
.then(function (r) {
body.removeChild(msg);
if (!r.ok) {
var e = $el('div', 'src-msg');
e.textContent = 'Ошибка ' + r.status;
body.appendChild(e);
return;
}
return r.json().then(function (files) {
var shown = files.filter(function (f) { return !f.binary; });
if (shown.length === 0) {
var e = $el('div', 'src-msg');
e.textContent = 'Код не загружен';
body.appendChild(e);
return;
}
for (var i = 0; i < shown.length; i++) {
var f = shown[i];
var block = $el('div', 'file-block');
var fhdr = $el('div', 'file-header');
fhdr.textContent = f.name;
var pre = document.createElement('pre');
var code = document.createElement('code');
code.className = 'language-' + detectLang(f.name);
code.textContent = f.content;
pre.appendChild(code);
block.appendChild(fhdr);
block.appendChild(pre);
body.appendChild(block);
}
hljs.highlightAll();
});
})
.catch(function (err) {
body.innerHTML = '';
var e = $el('div', 'src-msg');
e.textContent = 'Ошибка: ' + err;
body.appendChild(e);
});
}
function detectLang(name) {
var ext = name.split('.').pop().toLowerCase();
var m = {
py: 'python', js: 'javascript', ts: 'typescript', go: 'go',
sh: 'bash', bash: 'bash', yml: 'yaml', yaml: 'yaml',
json: 'json', md: 'markdown', txt: 'plaintext', toml: 'toml',
rs: 'rust', rb: 'ruby', java: 'java', cpp: 'cpp', c: 'c',
cs: 'csharp', php: 'php', html: 'html', css: 'css'
};
return m[ext] || 'plaintext';
}
function $el(tag, cls) { var e = document.createElement(tag); if (cls) e.className = cls; return e; }
function $txt(p, tag, cls, text) { var e = $el(tag, cls); e.textContent = text; p.appendChild(e); return e; }
})();
</script>
</body>
</html>
+499
View File
@@ -0,0 +1,499 @@
// Изменено: 2026-04-25 (добавлена HTML web-консоль, proxy endpoints для source/triggers)
// main.go — глобальный HTTP сервис листинга функций пользователя.
// Развёрнут ОДИН РАЗ в namespace sless; работает для ВСЕХ пользователей.
// Не связан с terraform — деплоится манифестом deployments/k8s/funcs-service.yaml.
//
// Маршруты:
// GET /funcs — usage hint (без токена)
// GET /funcs?token=<jwt> — листинг через JWT токен
// GET /funcs/<namespace> — листинг (Accept: text/html → HTML, иначе plain text)
// GET /funcs/<namespace>/source/<fn> — прокси к оператору: файлы исходного кода (JSON)
// PATCH /funcs/<namespace>/triggers/<n> — прокси к оператору: enable/disable триггера
// GET /health — liveness/readiness probe
//
// Env vars:
// SLESS_OPERATOR_URL — URL оператора внутри кластера (default: http://sless-operator.sless.svc.cluster.local:9090)
// SLESS_EXTERNAL_URL — публичный базовый URL для корректных ссылок на функции
// SLESS_EXCLUDE — comma-separated список имён функций, скрытых из листинга
// SLESS_SERVICE_TOKEN — токен сервиса для запросов к оператору (для /funcs/<namespace> без токена юзера)
// PORT — порт сервера (default: 8090)
package main
import (
"bytes"
"crypto/sha256"
_ "embed"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"regexp"
"sort"
"strings"
)
//go:embed index.html
var htmlPageTemplate string
// fnResponse — ответ /v1/namespaces/{ns}/functions (подмножество полей оператора)
type fnResponse struct {
Name string `json:"name"`
Runtime string `json:"runtime"`
Phase string `json:"phase"`
Message string `json:"message"`
CreatedAt string `json:"created_at"`
LastBuiltAt string `json:"last_built_at"`
}
// trResponse — ответ /v1/namespaces/{ns}/triggers
type trResponse struct {
Name string `json:"name"`
Type string `json:"type"`
FunctionRef string `json:"function"`
Schedule string `json:"schedule"`
Enabled bool `json:"enabled"`
Active bool `json:"active"`
URL string `json:"url"`
}
// pageData — структура данных для HTML-шаблона web-консоли.
type pageData struct {
Namespace string `json:"namespace"`
ExternalURL string `json:"externalURL"`
Functions []fnResponse `json:"functions"`
TriggersByFn map[string][]trResponse `json:"triggersByFn"`
}
// nsRegex — базовая валидация kubernetes namespace (ловеркейс + hyphens).
// Защита от path traversal при подстановке в URL запросов к оператору.
var nsRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$`)
func main() {
operatorURL := strings.TrimRight(env("SLESS_OPERATOR_URL", "http://sless-operator.sless.svc.cluster.local:9090"), "/")
externalURL := strings.TrimRight(env("SLESS_EXTERNAL_URL", ""), "/")
port := env("PORT", "8090")
serviceToken := os.Getenv("SLESS_SERVICE_TOKEN")
exclude := map[string]bool{}
for _, n := range strings.Split(os.Getenv("SLESS_EXCLUDE"), ",") {
if n = strings.TrimSpace(n); n != "" {
exclude[n] = true
}
}
// /funcs (точное совпадение) — namespace из JWT токена (?token= или Authorization header)
http.HandleFunc("/funcs", handleFuncsToken(operatorURL, externalURL, exclude))
// /funcs/ — namespace в URL пути + sub-paths (source/triggers proxy)
http.HandleFunc("/funcs/", handleFuncsNS(operatorURL, externalURL, serviceToken, exclude))
// /health — для liveness/readiness probe (без auth)
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintln(w, "ok")
})
log.Printf("sless-funcs-service listening on :%s (operator: %s)", port, operatorURL)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
// handleFuncsToken обрабатывает GET /funcs с JWT токеном (?token= или Authorization header).
func handleFuncsToken(operatorURL, externalURL string, exclude map[string]bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
}
if token == "" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintln(w, "Укажите namespace в URL или передайте токен:")
fmt.Fprintln(w, "")
fmt.Fprintf(w, " %s/funcs/<namespace>\n", externalURL)
fmt.Fprintf(w, " %s/funcs?token=<jwt>\n", externalURL)
return
}
sub, err := subFromJWT(token)
if err != nil {
http.Error(w, fmt.Sprintf("invalid token: %s\n", err), http.StatusUnauthorized)
return
}
fetchAndRender(w, r, operatorURL, externalURL, "Bearer "+token, namespaceFromSub(sub), exclude)
}
}
// handleFuncsNS обрабатывает все запросы /funcs/{ns}[/sub/path].
//
// Маршруты:
//
// GET /funcs/{ns} — листинг (HTML если Accept: text/html, иначе plain text)
// GET /funcs/{ns}/source/{fn} — прокси: файлы исходного кода функции
// PATCH /funcs/{ns}/triggers/{name} — прокси: enable/disable триггер
func handleFuncsNS(operatorURL, externalURL, serviceToken string, exclude map[string]bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rest := strings.TrimPrefix(r.URL.Path, "/funcs/")
rest = strings.Trim(rest, "/")
if rest == "" {
http.Error(w, "usage: /funcs/<namespace>\n", http.StatusBadRequest)
return
}
// SplitN до 3 частей: [ns], [ns, subtype] или [ns, subtype, resourceName]
parts := strings.SplitN(rest, "/", 3)
ns := parts[0]
if !nsRegex.MatchString(ns) {
http.Error(w, "invalid namespace\n", http.StatusBadRequest)
return
}
if len(parts) == 3 {
switch parts[1] {
case "source":
if r.Method != http.MethodGet {
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
return
}
proxySourceGet(w, r, operatorURL, serviceToken, ns, parts[2])
return
case "triggers":
if r.Method != http.MethodPatch {
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
return
}
proxyTriggerPatch(w, r, operatorURL, serviceToken, ns, parts[2])
return
}
}
if len(parts) > 1 {
http.NotFound(w, r)
return
}
// GET /funcs/{ns} — листинг с авторизацией через serviceToken
authHeader := ""
if serviceToken != "" {
authHeader = "Bearer " + serviceToken
}
fetchAndRender(w, r, operatorURL, externalURL, authHeader, ns, exclude)
}
}
// fetchAndRender загружает данные из оператора и отдаёт ответ: HTML (браузер) или plain text (curl).
func fetchAndRender(w http.ResponseWriter, r *http.Request, operatorURL, externalURL, authHeader, ns string, exclude map[string]bool) {
fns, err := apiGet[[]fnResponse](operatorURL+"/v1/namespaces/"+ns+"/functions", authHeader)
if err != nil {
http.Error(w, fmt.Sprintf("operator error (functions): %s\n", err), http.StatusBadGateway)
return
}
trs, err := apiGet[[]trResponse](operatorURL+"/v1/namespaces/"+ns+"/triggers", authHeader)
if err != nil {
http.Error(w, fmt.Sprintf("operator error (triggers): %s\n", err), http.StatusBadGateway)
return
}
if acceptsHTML(r) {
serveHTML(w, ns, externalURL, fns, trs, exclude)
} else {
servePlainText(w, ns, externalURL, fns, trs, exclude)
}
}
// acceptsHTML возвращает true если клиент предпочитает HTML (браузер).
// curl без -H "Accept: text/html" получит plain text — поведение совместимо с v0.1.x.
func acceptsHTML(r *http.Request) bool {
return strings.Contains(r.Header.Get("Accept"), "text/html")
}
// serveHTML рендерит web-консоль.
// Данные встраиваются как JSON в <script type="application/json"> — изолировано от HTML-контекста.
// </script> в данных экранируется как <\/script> (валидный JSON escape).
func serveHTML(w http.ResponseWriter, ns, externalURL string, fns []fnResponse, trs []trResponse, exclude map[string]bool) {
trigIdx := buildTriggerIndex(trs)
filtered := filterAndSort(fns, trigIdx, exclude)
pd := pageData{
Namespace: ns,
ExternalURL: externalURL,
Functions: filtered,
TriggersByFn: trigIdx,
}
raw, _ := json.Marshal(pd)
// Предотвращаем преждевременное закрытие тега <script type="application/json">
safeJSON := strings.ReplaceAll(string(raw), "</", `<\/`)
page := strings.ReplaceAll(htmlPageTemplate, "__PAGE_DATA__", safeJSON)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, page)
}
// servePlainText рендерит human-readable листинг для терминала (curl).
func servePlainText(w http.ResponseWriter, ns, externalURL string, fns []fnResponse, trs []trResponse, exclude map[string]bool) {
trigIdx := buildTriggerIndex(trs)
type entry struct {
fn fnResponse
httpT []trResponse
cronT []trResponse
}
var items []entry
for _, fn := range fns {
if exclude[fn.Name] {
continue
}
var httpT, cronT []trResponse
for _, tr := range trigIdx[fn.Name] {
switch tr.Type {
case "http":
httpT = append(httpT, tr)
case "cron":
cronT = append(cronT, tr)
}
}
items = append(items, entry{fn, httpT, cronT})
}
sort.Slice(items, func(i, j int) bool {
ai := isActive(items[i].fn.Name, trigIdx)
aj := isActive(items[j].fn.Name, trigIdx)
if ai != aj {
return ai
}
return items[i].fn.Name < items[j].fn.Name
})
sep := strings.Repeat("─", 52)
var sb strings.Builder
for _, it := range items {
fn := it.fn
sb.WriteString(sep + "\n")
sb.WriteString(" " + buildComment(fn, it.httpT, it.cronT) + "\n")
sb.WriteString(fmt.Sprintf(" name: %s\n", fn.Name))
sb.WriteString(fmt.Sprintf(" runtime: %s\n", fn.Runtime))
sb.WriteString(fmt.Sprintf(" phase: %s\n", fn.Phase))
if isActive(fn.Name, trigIdx) {
sb.WriteString(" active: да\n")
} else {
sb.WriteString(" active: нет\n")
}
if len(it.httpT) > 0 {
url := it.httpT[0].URL
if externalURL != "" {
url = externalURL + "/fn/" + ns + "/" + fn.Name
}
sb.WriteString(fmt.Sprintf(" url: %s\n", url))
}
if len(it.cronT) > 0 {
sb.WriteString(fmt.Sprintf(" cron: %s\n", it.cronT[0].Schedule))
}
if fn.CreatedAt != "" {
sb.WriteString(fmt.Sprintf(" created: %s\n", fn.CreatedAt))
}
if fn.LastBuiltAt != "" {
sb.WriteString(fmt.Sprintf(" built: %s\n", fn.LastBuiltAt))
}
if fn.Message != "" {
sb.WriteString(fmt.Sprintf(" message: %s\n", fn.Message))
}
}
sb.WriteString(sep + "\n")
sb.WriteString(fmt.Sprintf(" namespace: %s | total: %d\n", ns, len(items)))
sb.WriteString(sep + "\n")
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprint(w, sb.String())
}
// proxySourceGet проксирует GET /funcs/{ns}/source/{fn} → оператор GET /v1/.../source.
// Авторизация — сервисный токен (хранится на сервере, не открывается клиенту).
func proxySourceGet(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, funcName string) {
if !isValidK8sName(funcName) {
http.Error(w, "invalid function name\n", http.StatusBadRequest)
return
}
target := operatorURL + "/v1/namespaces/" + ns + "/functions/" + funcName + "/source"
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target, nil)
if err != nil {
http.Error(w, "internal error\n", http.StatusInternalServerError)
return
}
if serviceToken != "" {
req.Header.Set("Authorization", "Bearer "+serviceToken)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
http.Error(w, fmt.Sprintf("operator error: %s\n", err), http.StatusBadGateway)
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body) //nolint:errcheck
}
// proxyTriggerPatch проксирует PATCH /funcs/{ns}/triggers/{name} → оператор.
// Принимает только поле "enabled" — защита от произвольных изменений с сервисным токеном.
func proxyTriggerPatch(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, triggerName string) {
if !isValidK8sName(triggerName) {
http.Error(w, "invalid trigger name\n", http.StatusBadRequest)
return
}
// Читаем только поле enabled — остальные поля отбрасываем.
var body struct {
Enabled *bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Enabled == nil {
http.Error(w, `{"error":"required: {\"enabled\": true/false}"}`, http.StatusBadRequest)
return
}
reqBody, _ := json.Marshal(map[string]bool{"enabled": *body.Enabled})
target := operatorURL + "/v1/namespaces/" + ns + "/triggers/" + triggerName
opReq, err := http.NewRequestWithContext(r.Context(), http.MethodPatch, target, bytes.NewReader(reqBody))
if err != nil {
http.Error(w, "internal error\n", http.StatusInternalServerError)
return
}
opReq.Header.Set("Content-Type", "application/json")
if serviceToken != "" {
opReq.Header.Set("Authorization", "Bearer "+serviceToken)
}
resp, err := http.DefaultClient.Do(opReq)
if err != nil {
http.Error(w, fmt.Sprintf("operator error: %s\n", err), http.StatusBadGateway)
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body) //nolint:errcheck
}
// buildTriggerIndex строит индекс триггеров по имени функции (FunctionRef).
func buildTriggerIndex(trs []trResponse) map[string][]trResponse {
idx := map[string][]trResponse{}
for _, tr := range trs {
idx[tr.FunctionRef] = append(idx[tr.FunctionRef], tr)
}
return idx
}
// filterAndSort фильтрует функции (exclude) и сортирует: активные вверх, затем по имени.
func filterAndSort(fns []fnResponse, trigIdx map[string][]trResponse, exclude map[string]bool) []fnResponse {
result := make([]fnResponse, 0, len(fns))
for _, fn := range fns {
if !exclude[fn.Name] {
result = append(result, fn)
}
}
sort.Slice(result, func(i, j int) bool {
ai := isActive(result[i].Name, trigIdx)
aj := isActive(result[j].Name, trigIdx)
if ai != aj {
return ai
}
return result[i].Name < result[j].Name
})
return result
}
// isActive возвращает true если хотя бы один триггер функции включён и активен.
func isActive(funcName string, trigIdx map[string][]trResponse) bool {
for _, tr := range trigIdx[funcName] {
if tr.Enabled && tr.Active {
return true
}
}
return false
}
// isValidK8sName базовая проверка имени k8s-ресурса: нет path traversal символов.
func isValidK8sName(s string) bool {
return s != "" && !strings.Contains(s, "/") && !strings.Contains(s, "..") && len(s) <= 253
}
func buildComment(fn fnResponse, httpT, cronT []trResponse) string {
if len(httpT) > 0 {
active := "активна"
if !httpT[0].Active {
active = "неактивна"
}
return fmt.Sprintf("HTTP endpoint (%s) — %s, %s", fn.Runtime, fn.Phase, active)
}
if len(cronT) > 0 {
active := "активна"
if !cronT[0].Active {
active = "неактивна"
}
return fmt.Sprintf("Cron '%s' (%s) — %s, %s", cronT[0].Schedule, fn.Runtime, fn.Phase, active)
}
return fmt.Sprintf("Job/runner без триггера (%s) — %s", fn.Runtime, fn.Phase)
}
// subFromJWT декодирует JWT payload (без проверки подписи) и возвращает sub.
// Подпись не проверяется — trusted perimeter: сервис работает за Ingress.
func subFromJWT(token string) (string, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return "", fmt.Errorf("invalid jwt: expected 3 parts")
}
// base64url без паддинга — добавляем паддинг стандартно
payload := parts[1]
switch len(payload) % 4 {
case 2:
payload += "=="
case 3:
payload += "="
}
data, err := base64.URLEncoding.DecodeString(payload)
if err != nil {
return "", fmt.Errorf("decode payload: %w", err)
}
var claims map[string]any
if err := json.Unmarshal(data, &claims); err != nil {
return "", fmt.Errorf("unmarshal claims: %w", err)
}
sub, ok := claims["sub"].(string)
if !ok || sub == "" {
return "", fmt.Errorf("missing sub claim")
}
return sub, nil
}
// namespaceFromSub — та же логика что в операторе и terraform провайдере.
// SHA256(sub) → первые 8 байт → hex → "sless-{16 hex символов}"
func namespaceFromSub(sub string) string {
hash := sha256.Sum256([]byte(sub))
return fmt.Sprintf("sless-%x", hash[:8])
}
func apiGet[T any](url, authHeader string) (T, error) {
var zero T
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return zero, err
}
req.Header.Set("Authorization", authHeader)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return zero, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return zero, fmt.Errorf("status %d: %s", resp.StatusCode, body)
}
if err := json.Unmarshal(body, &zero); err != nil {
return zero, fmt.Errorf("unmarshal: %w", err)
}
return zero, nil
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+1 -1
View File
@@ -117,7 +117,7 @@ gpg --batch --yes --detach-sign --default-key "$key_id" \
"${BUILD_DIR}/terraform-provider-sless_${VERSION}_SHA256SUMS"
echo " Загружаем в S3..."
# mc = MinIO Client (/usr/local/bin/mc); Midnight Commander переименован в midn
# Алиас mc включается в путь: <alias>/<bucket>/<path>
mc alias set sless-registry "$s3_url" "$S3_ACCESS_KEY" "$S3_SECRET_KEY" --api S3v4 >/dev/null
S3_PATH="sless-registry/${S3_BUCKET}/${REGISTRY_HOSTNAME}/${NAMESPACE}/${PROVIDER_NAME}/${VERSION}/"
+8 -1
View File
@@ -1,4 +1,4 @@
// 2026-03-11
// 2026-03-17 12:20
// client.go — HTTP-клиент для REST API sless оператора.
// Изолирован от terraform-plugin-framework — зависит только от stdlib и net/http.
// Все методы принимают ctx для правильной работы с таймаутами terraform.
@@ -22,6 +22,7 @@ import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
@@ -44,6 +45,9 @@ type Client struct {
Namespace string
}
// ErrJobAlreadyExists возвращается при попытке создать FunctionJob с уже существующим именем.
var ErrJobAlreadyExists = errors.New("job already exists")
// New создаёт клиент.
// - endpoint — базовый URL оператора (без trailing slash), например "https://sless-api.kube5s.ru"
// - token — Bearer JWT-токен облака
@@ -456,6 +460,9 @@ func (c *Client) CreateJob(ctx context.Context, ns string, req JobRequest) (*Job
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusConflict {
return nil, fmt.Errorf("%w: %s", ErrJobAlreadyExists, strings.TrimSpace(string(body)))
}
return nil, fmt.Errorf("create job: status %d: %s", resp.StatusCode, body)
}
var j JobResponse
@@ -22,7 +22,6 @@ import (
"context"
"fmt"
"os"
"strconv"
"strings"
"terraform-provider-sless/internal/client"
@@ -46,7 +45,6 @@ type SlessProviderModel struct {
Endpoint types.String `tfsdk:"endpoint"`
Token types.String `tfsdk:"token"`
NubesEndpoint types.String `tfsdk:"nubes_endpoint"`
AIHintLevel types.Int64 `tfsdk:"ai_hint_level"`
}
// New возвращает фабрику провайдера — точная копия паттерна nubes.
@@ -77,10 +75,6 @@ func (p *SlessProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp
MarkdownDescription: "URL nubes API для валидации токена (env: NUBES_ENDPOINT). Опционально. Пример: https://deck-api.ngcloud.ru/api/v1",
Optional: true,
},
"ai_hint_level": schema.Int64Attribute{
MarkdownDescription: "Уровень AI-анализа terraform plan: 0=off, 1=syntax, 2=basic, 3=moderate, 4=detailed, 5=full. По умолчанию 0 (выключен).",
Optional: true,
},
},
}
}
@@ -166,20 +160,6 @@ func (p *SlessProvider) Configure(ctx context.Context, req provider.ConfigureReq
}
}
// --- 7. Экспортируем ai_hint_level в env AI_HINT_LEVEL для sless-plan ---
// sless-plan читает AI_HINT_LEVEL при запуске — так уровень из provider "sless" подхватывается автоматически.
// Если атрибут не задан — уровень 0 (AI выключен), env не выставляется.
if !config.AIHintLevel.IsNull() {
level := int(config.AIHintLevel.ValueInt64())
if level < 0 {
level = 0
}
if level > 5 {
level = 5
}
os.Setenv("AI_HINT_LEVEL", strconv.Itoa(level))
}
resp.ResourceData = c
resp.DataSourceData = c
}
@@ -1,4 +1,4 @@
// 2026-03-08
// 2026-03-17 12:20
// job_resource.go — Terraform ресурс sless_job.
//
// Lifecycle:
@@ -17,6 +17,7 @@ package resources
import (
"context"
"errors"
"fmt"
"time"
@@ -163,15 +164,28 @@ func (r *JobResource) Create(ctx context.Context, req resource.CreateRequest, re
// run_id=0: создаём FunctionJob в k8s, но оператор не запустит k8s Job.
// Пользователь может поменять run_id > 0 позже чтобы запустить.
runID := plan.RunID.ValueInt64()
_, err := r.client.CreateJob(ctx, ns, client.JobRequest{
createdJob, err := r.client.CreateJob(ctx, ns, client.JobRequest{
Name: plan.Name.ValueString(),
FunctionRef: plan.Function.ValueString(),
EventJSON: eventJSON,
RunID: runID,
})
if err != nil {
resp.Diagnostics.AddError("create job", err.Error())
return
if errors.Is(err, client.ErrJobAlreadyExists) {
existingJob, getErr := r.client.GetJob(ctx, ns, plan.Name.ValueString())
if getErr != nil {
resp.Diagnostics.AddError("create job", fmt.Sprintf("job already exists and get existing failed: %s", getErr.Error()))
return
}
if existingJob == nil {
resp.Diagnostics.AddError("create job", "job already exists but cannot be read right after conflict")
return
}
createdJob = existingJob
} else {
resp.Diagnostics.AddError("create job", err.Error())
return
}
}
// Если RunID=0 — не ждём завершения, пишем state сразу
@@ -187,10 +201,10 @@ func (r *JobResource) Create(ctx context.Context, req resource.CreateRequest, re
EventJSON: plan.EventJSON,
RunID: types.Int64Value(0),
WaitTimeoutSec: waitTimeoutSec,
Phase: types.StringValue("Skipped"),
StartTime: types.StringValue(""),
CompletionTime: types.StringValue(""),
Message: types.StringValue("run_id=0: set run_id>0 to execute"),
Phase: types.StringValue(createdJob.Phase),
StartTime: types.StringValue(createdJob.StartTime),
CompletionTime: types.StringValue(createdJob.CompletionTime),
Message: types.StringValue(createdJob.Message),
})...)
return
}