Author SHA1 Message Date
Repinoid 8c5299128d chore: update ignore rules 2026-10-27 14:09:07 +03:00
Naeel 7c9915c308 feat(vm): add vApp + VM terraform example 2026-03-25 08:17:08 +03:00
Naeel 5ae20ce970 feat(builder): py_compile + node --check при сборке ловят синтаксические ошибки (v0.1.63) 2026-03-23 11:23:01 +03:00
Naeel 1829a3b8f9 fix(web-console): убраны память/таймаут, двойной рендер кода (v0.1.11) 2026-03-23 11:14:32 +03:00
Naeel bfd581e643 fix(web-console): NotFoundError — urlRow создаётся сразу, не через insertBefore (v0.1.10) 2026-03-23 11:05:30 +03:00
Naeel 96f17c3cac feat(web-console): URL строкой ниже, статус сборки после Save (v0.1.9) 2026-03-23 11:01:01 +03:00
Naeel 9a25c27d8a fix(web-console): таймер исчезает после Building→Ready, шаблоны без context (v0.1.8) 2026-03-23 10:52:37 +03:00
Naeel 9a49883304 fix(web-console): zip invalid — Close() после Bytes() (v0.1.6) 2026-03-23 10:47:25 +03:00
Naeel d2ff564d9f feat(web-console): deps, rename, Building poll+timer, kind=service — v0.1.5 2026-03-23 10:41:43 +03:00
Naeel 44e331b93c feat(web-console): create/edit/delete functions in UI, v0.1.4
- services/funcs: новые маршруты POST create-function, POST save-function,
  DELETE delete-function — создание/редактирование/удаление через браузер
- index.html: модалка создания с шаблонами Python/Node.js hello world,
  кнопки edit/delete на каждой карточке функции
- sless-funcs-service:v0.1.4 задеплоен
- examples/POSTGRES: удалены логи, .bak, backup tfstate, лишние функции
  оставлены 2 Python (pg-stats, pg-counter) + 2 Node.js (pg-info, js-idempotent)
2026-03-23 10:19:42 +03:00
Naeel defbed0363 fix: cache lag retry, go.work, провайдер rollback, test v4 no -target 2026-03-23 10:03:26 +03:00
“Naeel” 2691eb2f57 fix: go.work replace + test destroy targets 2026-03-23 09:30:37 +04:00
Naeel 16807a171d feat(v0.1.60): sha256-based s3Key for content-addressed cache hit 2026-03-23 06:57:18 +03:00
Naeel d03b595828 feat(v0.1.59): in-cluster registry:2 — insecure HTTP mode, ImageExists error handling 2026-03-23 06:41:30 +03:00
Naeel c6a71cf671 feat(v0.1.58): ImageExists cache hit, timing analysis, in-cluster registry plan 2026-03-23 06:22:25 +03:00
Naeel cbafe4380b feat(builder): add REGISTRY_PROJECT for easy registry migration
DockerHub (flat): REGISTRY_HOST=naeel, REGISTRY_PROJECT=<empty>
  -> naeel/{nsPrefix}-{func}:{tag}
Harbor/GCR (project): REGISTRY_HOST=host, REGISTRY_PROJECT=proj
  -> host/proj/{func}:{tag}

Switch registry by changing 2 env vars only.
2026-03-22 17:51:05 +03:00
Naeel 34fd9a431d fix(builder/go1.23): add require sless/fn/handler to server/go.mod at build time
go.work replace rule requires explicit require directive in server/go.mod.
Patch appended at kaniko build time - no base image rebuild needed.
2026-03-22 17:28:40 +03:00
“Naeel” b939cc7048 0 2026-03-22 17:04:01 +04:00
Naeel fba287c02a fix: multiuser test — memory_mb + zip invalid + early return 2026-03-22 15:00:55 +03:00
Naeel 0ea31bf0a6 test: G_MULTIUSER — 10 parallel users + Postgres (ready to run) 2026-03-22 14:36:52 +03:00
Naeel 23a477f0ba test: G17/G18/G19/G20/G22 - 154/154 PASS 2026-03-22 14:09:15 +03:00
Naeel bedd91a4f3 feat: v0.1.51 + G13/G14/G15 tests (126/126 PASS)
- fix: UpdateService IsInvalid → 400 (was 500 for ruby3.0 runtime)
- test: G13 edge cases — 40 tests, 40 PASS (name validation, boundary values,
  lifecycle, state transitions, update validation, upload edge cases)
- test: G14 cluster chaos — 20 tests, 20 PASS (self-healing, pod kill,
  OOM kill, kaniko interrupt, operator restart)
- test: G15 combined chaos — 21 tests, 21 PASS (CRUD under chaos, upload
  during self-heal, concurrent creates, errors after restart, rapid lifecycle)
- doc: progress.md, errors/log.md, decisions/log.md — полная документация сессии
2026-03-22 11:15:46 +03:00
Naeel a1d58a8f63 fix: v0.1.50 — runtime 400 + SLESS_ENTRYPOINT в Deployment
Bug 1: services.go — k8s IsInvalid error (CRD enum validation) маппился в 500.
Теперь errors.IsInvalid() → 400 Bad Request (invalid service spec).

Bug 2: service_controller.go buildServiceDeployment не передавал env SLESS_ENTRYPOINT
в под. Добавлен в envVars из svc.Spec.Entrypoint. Без него server.py использовал
fallback handler.handle и не замечал неверный entrypoint.

operator_failure_test.sh 12B-2: обновлён под новое правильное поведение —
create ruby3.0 → 400 (не 201). Старый 201-путь сохранён как warn для совместимости.
2026-03-22 08:11:05 +03:00
Naeel 5aaeafd5f0 test: operator_failure_test.sh — group 12 failure modes (43/45) 2026-03-22 07:49:58 +03:00
Naeel 8a2d642685 fix: survival test — время в GMT+4 (TZ=Asia/Dubai) 2026-03-22 07:11:05 +03:00
Naeel 33e908f7cd test: survival+pg — group 11 PG STORM + параметризация TOKEN/NS/PG
- TOKEN/NS параметризованы через env SLESS_TOKEN / SLESS_NS
- PG_HOST/PORT/USER/PASSWORD/DATABASE/TABLE добавлены в CONFIG
- make_python_pg_zip: Python handler с psycopg2 (init/insert/count)
- create_pg_deploy, pg_invoke_burst, pg_count_fn, pg_init_fn
- Group 11 PG STORM: 11 тестов, 300+ параллельных INSERT/COUNT
- Итоговый banner обновлён (G11 + PG STORM label)
2026-03-22 06:55:31 +03:00
Naeel bd0abd5712 fix: survival test — false alarm at 5.2, empty CLEANUP entries, teardown guard 2026-03-21 19:22:50 +03:00
Naeel b43ff18626 test: operator_survival_test.sh — survival suite (группы 5-10)
5 FLOOD BUILD:         6 функций (3×Python + 3×Node.js) параллельно → все Ready
6 RAPID DISCARD STORM: 30 create→DELETE без build, orphan-check
7 CHURN UNDER FIRE:    10 PUT env-updates под 200 параллельными invokes, rate≥90%
8 PHOENIX:             3 цикла DELETE+recreate одного имени
9 SELF-HEALING MARATHON: 5×kubectl delete deployment → auto-recreate (RequeueAfter)
10 INVOKE HURRICANE:   500 параллельных invokes в 5 волнах (100 × 5), rate≥90%

Ожидаемое время прогона: 75-100 минут
2026-03-21 19:16:30 +03:00
Naeel 58bdf2b55a fix: operator v0.1.49 — Resources update + self-healing requeue
БАГ 1: ensureServiceDeployment UPDATE блок теперь обновляет Containers[0].Resources
  → memory_mb через PUT применяется к k8s Deployment

БАГ 2: ensureServiceDeployment возвращает RequeueAfter: 60s вместо Result{}
  → ручное удаление Deployment пересоздаётся контроллером в течение 60s

lifecycle-тест: 47/47 PASS (ранее 44/44 с 2 known bugs)
2026-03-21 18:51:02 +03:00
Naeel c87edc6964 docs: lifecycle test — 44/44 PASS; 2 бага оператора задокументированы в errors/log.md + progress.md
Баги:
  1. memory_mb через PUT не обновляет k8s Deployment Resources → controllers/service_controller.go:~241
  2. нет self-healing при ручном удалении Deployment → нет RequeueAfter / cross-namespace watch
2026-03-21 18:32:14 +03:00
Naeel 531d3fe0bb test(operator): lifecycle test — 44/44 PASS; 2 бага оператора задокументированы
Группы тестов:
  1. Валидация API (8 тестов) — memory_mb, timeout_sec, missing fields, 404
  2. Полный цикл одной функции (18 тестов):
     - create → build → ready → invoke → env update → memory update → timeout → delete
     - delete → k8s Deployment/Service удалены
  3. Граничные сценарии:
     - DELETE while Building → kaniko убит, Deployment не создан
     - Reconcile: kubectl delete Deployment → [БАГ: не пересоздаётся]
  4. Параллельные функции (10 тестов):
     - 3 функции одновременно → delete одной в Building → 2 доходят до Ready
     - 40 параллельных invoke → 40/40 OK

Найденные баги оператора:
  БАГ 1: ensureServiceDeployment не обновляет Resources (memory_mb через PUT игнорируется)
  БАГ 2: нет self-healing — если Deployment удалён вручную, контроллер не пересоздаёт
         (нет cross-namespace watch, RequeueAfter не задан)
2026-03-21 17:58:14 +03:00
Naeel 2e583ddb12 feat(service): timeout_sec без дефолта; 0=нет таймаута; operator v0.1.48
- api/v1alpha1/service_types.go: убрать +kubebuilder:default=30
- invoke.go: TimeoutSec=0 → &http.Client{} (без таймаута)
- services.go: валидация timeout_sec < 0 || > 900 → HTTP 400
- service_resource.go: TF schema Optional (без Computed); 0 → Int64Null()
- deployments/k8s/operator.yaml: v0.1.47 → v0.1.48
- doc/: progress.md + api/design.md (модель Service) + decisions/log.md
- examples/POSTGRES/: bug_hunter.sh, chaos_marathon.sh, chaos_marathon.tf
2026-03-21 16:58:43 +03:00
Naeel 53322a128a chore: удаление устаревших examples, новый doc, правки funcs-service
- examples/: удалены старые директории (TNAR, demo-event-log, demo-managed-functions,
  hello-go, hello-node, notes-python, pg-list-python, simple-node, simple-python)
- examples/README.md: обновлён (только POSTGRES остался)
- doc/decisions/build-deploy-pipeline.md: новый документ по пайплайну сборки/деплоя
- services/funcs/main.go: правки из текущей сессии
2026-03-21 08:46:30 +03:00
Naeel 7f79d2db7d fix(runtime): Go panic→500 (recover), Python exception→500+threading+backlog
- go1.23 v0.1.2: defer recover() в HTTP handler — panic no longer closes connection → HTTP 500
- python3.11 v0.1.5: try/except в do_GET/_handle_with_body/do_HEAD → 500 вместо EOF
- python3.11 v0.1.5: ThreadingHTTPServer — concurrent requests (был single-thread)
- python3.11 v0.1.6: _HighBacklogHTTPServer(request_queue_size=128) — listen(128) вместо listen(5)
- context.go: go1.23 v0.1.1→v0.1.2, python3.11 v0.1.4→v0.1.6
- operator: v0.1.45 → v0.1.47 (два деплоя подряд с новыми runtime-версиями)
- examples/POSTGRES: stress.tf (10 сервисов), full_test.sh (48 тестов, 4 фазы)
- examples/POSTGRES: README.md, очистка от старых файлов (luceUNDnode.tf, funcs_list.py)

Результат: full_test.sh 48/48 PASS
- Фаза 3 PG-стресс: 40/40 parallel writer OK, 30/30 js-async OK, pgstorm 14k ops 0 err
- Фаза 4 краш-шторм: 75/75 × HTTP 500 (паники не роняют платформу)
2026-03-21 08:42:03 +03:00
Naeel 822997d3ac docs: log examples cleanup session 2026-03-21 2026-03-21 07:50:09 +03:00
Naeel 2ab67338ae docs: document session 2 — API testing, DELETE 404 fix, funcs-console, source for services 2026-03-21 07:31:23 +03:00
Naeel f875357927 fix(source): add /services/{name}/source endpoint; fix 404 for service code view in funcs-console 2026-03-21 07:20:03 +03:00
Naeel 98fc906b0a fix(deploy): imagePullSecrets + образ v0.2.1 в funcs-service.yaml 2026-03-21 07:09:36 +03:00
Naeel 389c74936d feat(funcs-console): показывать sless_service вместе с функциями — единый листинг 2026-03-21 07:00:59 +03:00
Naeel a81c475767 fix(api): DELETE несуществующего ресурса — 404 вместо 204 (function/service/trigger/job) 2026-03-21 06:45:55 +03:00
Naeel 8a7f62423a docs(progress): итоги deploy v0.1.43, terraform apply POSTGRES Succeeded 2026-03-21 06:32:50 +03:00
Naeel b2695c4a7d chore: operator image v0.1.43 2026-03-21 06:24:01 +03:00
Naeel 872444b092 fix(controller): ждать S3Key перед startJobBuild — provider загружает код после создания CRD 2026-03-21 06:21:16 +03:00
Naeel 16317f40d9 chore(postgres): provider version 0.1.18 → 0.1.19 (sless_job self-contained) 2026-03-20 22:08:19 +03:00
Naeel 5a0b901a81 chore(crd): регенерация — FunctionJobSpec самодостаточен (runtime/entrypoint/env) 2026-03-20 22:02:28 +03:00
Naeel 18f5f4db91 fix(operator): добавить imagePullSecrets sless-registry-auth для pearlharbor 2026-03-20 22:00:20 +03:00
Naeel 7958018454 chore: operator image v0.1.42 — pearlharbor registry 2026-03-20 21:56:16 +03:00
Naeel 297cb7cfd7 fix(invoke): заменить FunctionRef на поля из Function.Spec — FunctionJobSpec самодостаточен 2026-03-20 21:52:49 +03:00
Naeel 23da7589b0 docs: убрать упоминания 192.168.1.220, исправить шаблоны SSH 2026-03-20 21:49:51 +03:00
Naeel 8976e3a6c7 feat(job): merge sless_function into sless_job — self-contained build+run
- FunctionJobSpec: убран FunctionRef, добавлены Runtime/Entrypoint/Env/S3Key/MemoryMB/TimeoutSec
- FunctionJobStatus: новый ImageRef, новая фаза Building
- FunctionJobReconciler: Building фаза (kaniko), убрана зависимость от Function CRD
  Builder+OperatorNamespace как поля struct; аннотация sless.kube5s.ru/build-job guard
- main.go: Builder+OperatorNamespace переданы в FunctionJobReconciler
- jobs.go handler: jobRequest/jobResponse без FunctionRef; новый UploadJobCode handler
- router.go: /jobs/{name}/upload маршрут
- client.go: JobRequest/JobResponse обновлены; UploadJobCode; uploadCodeToURL общий хелпер
- job_resource.go: полная переработка — источник/среда встроены в JobModel, ModifyPlan,
  Create с upload, wait_timeout_sec=900 по умолчанию (kaniko + выполнение)
- examples/POSTGRES/functions.tf: раскомментирован, sless_function удалён,
  sless_job самодостаточен (inline source_dir/runtime/entrypoint/env_vars)
2026-03-20 21:27:35 +03:00
Naeel fcb2cdac7c fix(postgres): исправлен api_endpoint nubes, try() для vault_secrets, стресс-скрипт, документация ошибок 2026-03-20 20:14:36 +03:00
Naeel 97a64021b0 chore: comment out all sless resources before PG instance deletion 2026-03-20 13:52:50 +03:00
Naeel 126c9f5900 feat: remove stress_* functions from examples/POSTGRES 2026-03-20 13:19:39 +03:00
Naeel e0f70cf402 feat: sless_service CRD + ServiceReconciler, RBAC fix, split postgres/functions.tf, operator v0.1.41 2026-03-20 13:03:12 +03:00
Naeel 6485f9727d fix: SSH ключ перенесён в ~/.ssh/naeel_vm_id_ed25519 (локально, вне sshfs) 2026-03-20 09:46:32 +03:00
Naeel 0bf89f7de9 doc: стресс-тест ✅ — 45918 ops, 0 ошибок, 76.5 ops/sec за 600s 2026-03-19 21:51:22 +03:00
Naeel 0d99878ba1 doc: Go runtime v0.1.1, баги 4+5, решения pgx/v5 + dynamic timeout
- progress.md: секция v0.1.1 → ✅ ЗАВЕРШЕНО, все задачи, таблица таймаутов, арх. функции
- errors/log.md: Баг 4 (invoke.go 30s хардкод → context deadline exceeded) + Баг 5 (nginx ingress отсутствие proxy-read-timeout → 504)
- decisions/log.md: pgx/v5 vs database/sql+lib/pq (с обоснованием), динамический таймаут (почему +5s, почему не кешировать, деградация)
2026-03-19 21:39:34 +03:00
Naeel 4366a50d59 feat: Go runtime v0.1.1 (pgx/v5), stress-go-pgstorm, fix invoke.go dynamic timeout, nginx ingress timeout 900s
- runtimes/go1.23: добавлен pgx/v5 v5.7.2 в go.mod, сгенерирован go.sum
- runtimes/go1.23/Dockerfile: один stage golang:1.23-alpine, go mod download кеширует зависимости
- internal/builder/context.go: тег Go runtime v0.1.0 → v0.1.1
- internal/api/handler/invoke.go: таймаут прокси-клиента теперь динамический из Function.Spec.TimeoutSec + 5s буфер (был хардкод 30s)
- examples/POSTGRES/code/stress-go-pgstorm/handler.go: новая функция, 100 горутин, pgxpool, INSERT/COUNT/MAX, параметры: workers/duration_sec/max_delay_ms
- examples/POSTGRES/resources.tf: добавлены sless_function.stress_go_pgstorm + trigger, timeout_sec=700
- deployments/k8s/operator.yaml: nginx ingress proxy-read-timeout=900s, proxy-send-timeout=900s
- examples/*/main.tf: исправлен URL deck-api-test.ngcloud.ru → deck-test.ngcloud.ru (все 9 файлов)
- Оператор: v0.1.39 (pgx/v5) → v0.1.40 (dynamic timeout)
2026-03-19 21:33:41 +03:00
Naeel fb14b6f754 doc: Tests 3-7, 8 стресс-функций, план Go runtime v0.1.1 + pgx/v5 2026-03-19 20:31:49 +03:00
Naeel 9baaec6f0d test: 8 стресс-функций (py/go/js), crash-тесты, stress_test.sh — 32 строки в PG 2026-03-19 19:44:26 +03:00
Naeel 7c8a54148f test: полный прогон POSTGRES example — changes, delete/recreate, new function 2026-03-19 18:45:18 +03:00
Naeel f04310d1b4 fix: убрать --no-cache из kaniko Args — флаг не поддерживается в gcr.io/kaniko-project/executor:latest, кэш отключён по умолчанию 2026-03-19 17:38:07 +03:00
Naeel 2f42662fc9 feat: event-dispatcher v0.1.0 — Dockerfile, деплой в кластер 2026-03-19 13:51:27 +03:00
Naeel 4edd5554c8 feat: event-trigger (Вариант A) — TriggerTypeEvent, event-dispatcher, reconcileEvent 2026-03-19 13:44:35 +03:00
Naeel d54b4c0ce9 doc: архитектура event-trigger — выбран Вариант A (отдельный event-dispatcher) 2026-03-19 13:21:57 +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
“Naeel” 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
“Naeel” ad77f5a021 chore: terraform.tfvars.change — инструкция по переименованию 2026-03-11 18:38:51 +04:00
“Naeel” f674023116 chore: добавлены terraform.tfvars.change — шаблоны для токена (переименовать в terraform.tfvars) 2026-03-11 18:38:07 +04:00
“Naeel” 19b245bdab fix: ModifyPlan — terraform plan теперь видит изменения в source_dir (provider v0.1.16) 2026-03-11 18:27:46 +04:00
“Naeel” 27731ee5f7 security: убраны реальные токены из terraform.tfvars + инструкция в README 2026-03-11 17:41:34 +04:00
“Naeel” 987a58fb03 docs: README для примера pg-list-python 2026-03-11 17:24:34 +04:00
“Naeel” b1cad18531 docs: progress.md — harbor integration + UX fixes (v0.1.24–v0.1.28) 2026-03-11 17:23:32 +04:00
“Naeel” 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
“Naeel” 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
“Naeel” 09a955af5c fix: восстановлен requirements.txt после теста 2026-03-11 16:54:53 +04:00
“Naeel” 2fec2dad18 test: невалидный requirements.txt 2026-03-11 16:53:01 +04:00
“Naeel” d084cb5fed docs: переработан examples/README.md + комментарии function.tf 2026-03-11 16:48:01 +04:00
“Naeel” 0d97da1faf fix: Decimal→float для JSON сериализации price 2026-03-11 16:39:56 +04:00
“Naeel” 62bf905a6d feat: пример pg-list-python — список из PostgreSQL без джоба 2026-03-11 16:37:22 +04:00
“Naeel” 863a2b1b06 chore: удалён отладочный push-sample (Harbor интеграция закончена) 2026-03-11 16:33:26 +04:00
“Naeel” 5ed8f07c2e fix: go1.23 build context — COPY . /app/handler/ вместо COPY handler/ 2026-03-11 16:14:42 +04:00
“Naeel” e381c00bec refactor: rename handler.go→greeting.go, buildGreeting() in hello-go example 2026-03-11 16:02:49 +04:00
“Naeel” 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
“Naeel” 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
“Naeel” 9a0dfc448c Ignore nested examples .git 2026-03-11 14:45:35 +04:00
“Naeel” dcf2f68fd2 chore: staged files from prev session (gitignore, examples, harbor guide) 2026-03-11 14:36:32 +04:00
“Naeel” 8b92fd125c docs: удалённая машина теперь полная — kubectl + kubeconfig скопирован 2026-03-11 12:58:39 +04:00
“Naeel” d9210eba5e docs: stress test 4/4 PASS (v0.1.23), баги задокументированы 2026-03-11 12:41:04 +04:00
“Naeel” 9cba8f07dc fix: operator v0.1.23 — BackoffLimit=2 в kaniko, пересоздание Failed функций; script: mod2 96→128 2026-03-11 12:26:06 +04:00
“Naeel” bdff746ed2 fix: run_stress_test.sh — tf_output_json для объектных outputs, fix pipeline в assert_step 2026-03-11 11:35:27 +04:00
“Naeel” 4bc1d681cd chore: gitignore logs dirs 2026-03-11 11:14:28 +04:00
“Naeel” 67440f1edf test: run_stress_test.sh — полный стресс-тест всех примеров 2026-03-11 11:14:06 +04:00
“Naeel” a3c1632cee test: E2E скрипт run_e2e_tests.sh + fix provider token/version в примерах 2026-03-11 10:49:36 +04:00
“Naeel” edcf658cdd docs: update for v0.1.22 — Builder SoC, immediate fixes, unit tests 2026-03-11 09:42:12 +04:00
“Naeel” 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
“Naeel” 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
“Naeel” 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
“Naeel” 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
“Naeel” 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
“Naeel” 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
“Naeel” c937e52013 docs: clarify separate providers decision; fix invocations 501 stub 2026-03-11 07:24:34 +04:00
“Naeel” 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
“Naeel” 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
“Naeel” 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
“Naeel” 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
“Naeel” 6c11ebd1e1 docs: clarify sless_function description 2026-03-09 20:24:59 +04:00
“Naeel” a914d60826 docs: remove informal tone from README 2026-03-09 20:24:23 +04:00
“Naeel” b6bb1a01cd docs: add project description and resource types to examples README 2026-03-09 20:23:31 +04:00
“Naeel” 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
“Naeel” 58e9e6119b fix: restore token in provider blocks (dev-token-change-me is the real token) 2026-03-09 19:51:27 +04:00
“Naeel” 50100040e8 chore: remove hardcoded token from examples, use SLESS_API_TOKEN env 2026-03-09 19:25:52 +04:00
“Naeel” 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
“Naeel” 6aa01978ad chore: удалён пример pg-query 2026-03-09 17:46:52 +04:00
“Naeel” 94ed02f631 feat: provider v0.1.10 — source_dir (zip внутри провайдера), убран archive провайдер из всех примеров 2026-03-09 17:45:45 +04:00
“Naeel” addce19cfe docs: examples/README.md — порядок simple-first, без токена, без pg-query 2026-03-09 15:57:29 +04:00
“Naeel” 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
“Naeel” d0ef085705 feat: provider v0.1.9 — namespace removed from resources, moved to provider block 2026-03-09 15:12:07 +04:00
“Naeel” 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
“Naeel” d075a7c0f6 feat: outputs — статус init-джобов для пользователя без kubectl 2026-03-09 14:09:55 +04:00
“Naeel” 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
“Naeel” 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
“Naeel” 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
“Naeel” 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
“Naeel” 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
“Naeel” 360f136989 refactor: pg-query — ресурсы вынесены в pg-query.tf, main.tf только провайдеры 2026-03-09 09:11:31 +04:00
“Naeel” dabb290b7c refactor: pg-query — code/ исходники, dist/ zip, archive_file datasource 2026-03-09 09:07:57 +04:00
“Naeel” 593bc3b2ad refactor: hello-node — zip-файлы в dist/, исходники остаются в code/ 2026-03-09 09:03:00 +04:00
“Naeel” 00a350e24b fix: pg-query — filesha256(handler.py) вместо filemd5(handler.zip), версия провайдера 0.1.7 2026-03-09 08:56:19 +04:00
“Naeel” 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
“Naeel” 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
“Naeel” 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
“Naeel” 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
“Naeel” 2180c68e01 docs: обновление doc/ по итогам сессии 2026-03-08 2026-03-08 20:36:16 +04:00
“Naeel” 67914155b9 fix: убрать UseStateForUnknown с image_ref, provider v0.1.5 2026-03-08 20:34:00 +04:00
“Naeel” 953889db5a config: REGISTRY_HOST переключён с Harbor на DockerHub (naeel) 2026-03-08 20:18:59 +04:00
“Naeel” a58d3b9af5 fix: FunctionJob зависал в Running — добавлен RequeueAfter polling 2026-03-08 12:13:15 +04:00
“Naeel” 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
“Naeel” 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
“Naeel” 19fe8e41e5 ops: switch registry from DockerHub to Harbor (pearlharbor.registryk8s.services.ngcloud.ru/sless) 2026-03-08 09:28:18 +04:00
“Naeel” aad33b79a4 examples/hello-node: two different functions (http greeting, job sum) 2026-03-08 09:22:38 +04:00
“Naeel” 87f28d8b19 examples/hello-node: move handler to code/, split http.tf and job.tf 2026-03-08 09:14:34 +04:00
“Naeel” a9ff7ace7f examples: switch hello-node to sless_job (one-shot run) 2026-03-07 23:05:09 +04:00
“Naeel” 11c6faf4e9 examples: auto-zip via hashicorp/archive, remove manual zip step 2026-03-07 18:46:23 +04:00
“Naeel” 221c46e7f7 doc: обновлена документация — FunctionJob, proxy /fn/, operator v0.1.5, provider v0.1.3 2026-03-07 18:40:36 +04:00
“Naeel” 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
“Naeel” 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
“Naeel” 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
“Naeel” 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
“Naeel” 7fc9bef96e doc: document operator deploy, nodejs20 runtime, Dockerfile fixes, go.mod fix 2026-03-07 17:02:28 +04:00
“Naeel” 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
“Naeel” 6bcdb31f25 chore: switch pg-query example to remote endpoint sless-api.kube5s.ru 2026-03-07 16:50:05 +04:00
“Naeel” b5fe310e33 chore: go mod tidy — fix direct/indirect annotations 2026-03-07 16:31:54 +04:00
“Naeel” 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
“Naeel” 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
“Naeel” 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
“Naeel” 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
“Naeel” 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
“Naeel” 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
“Naeel” 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
“Naeel” 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
“Naeel” 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
“Naeel” 37ff9dd5d2 docs: обновить progress.md по фактическому состоянию
- отражён реальный статус всех компонентов кода
- отражено состояние кластера (CRD, RBAC, postgres)
- добавлен список что ещё не сделано
- добавлены deployments/k8s/rbac.yaml, postgres.yaml
2026-03-07 08:57:54 +04:00
“Naeel” 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
“Naeel” df5de1fc4c chore: ignore .s3cfg* files 2026-03-07 08:28:33 +04:00
“Naeel” 8854750445 chore: remove .s3cfg_registry from repo 2026-03-07 08:28:19 +04:00
“Naeel” 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
“Naeel” 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
78 changed files with 2497 additions and 1891 deletions
-1
View File
@@ -75,4 +75,3 @@ examples/POSTGRES/stress_log*.txt
/s3cfg_registry
/sqs-operator/quick_test.sh
/sqs-operator/sqs_load_test.sh
/.s3cfg_registry
+33 -3
View File
@@ -106,11 +106,41 @@ func (r *FunctionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
const finalizerName = "sless.kube5s.ru/finalizer"
// startBuild запускает kaniko Job и помечает функцию как Building.
// startBuild проверяет наличие образа в registry и либо пропускает сборку,
// либо запускает kaniko Job. Идемпотентность: если код не менялся (тег = hash s3Key),
// образ уже в registry → deploy без пересборки.
// Критически важно: СНАЧАЛА сохраняем last-built-s3key аннотацию, ПОТОМ status.
// Это предотвращает повторный запуск сборки при параллельных reconcile —
// следующий reconcile увидит last-built-s3key == spec.S3Key и не войдёт в startBuild.
func (r *FunctionReconciler) startBuild(ctx context.Context, fn *slessv1alpha1.Function) (ctrl.Result, error) {
imageRef := r.Builder.ImageRef(fn.Namespace, fn.Name, fn.Spec.S3Key)
// Проверяем: образ с этим тегом уже существует в registry?
// Если да — пропускаем kaniko, сразу переходим в Ready.
// Если registry недоступен — requeue, не запускаем сборку (kaniko тоже упадёт).
exists, err := r.Builder.ImageExists(ctx, imageRef)
if err != nil {
return ctrl.Result{RequeueAfter: 10 * time.Second}, fmt.Errorf("check image exists: %w", err)
}
if exists {
logger := log.FromContext(ctx)
logger.Info("image already exists in registry, skipping build", "imageRef", imageRef)
if fn.Annotations == nil {
fn.Annotations = map[string]string{}
}
fn.Annotations["sless.kube5s.ru/last-built-s3key"] = fn.Spec.S3Key
if err := r.Update(ctx, fn); err != nil {
return ctrl.Result{}, fmt.Errorf("update annotations (cache hit): %w", err)
}
fn.Status.Phase = slessv1alpha1.FunctionPhaseReady
fn.Status.ImageRef = imageRef
fn.Status.Message = "Image restored from registry cache"
if err := r.Status().Update(ctx, fn); err != nil {
return ctrl.Result{}, fmt.Errorf("update status (cache hit): %w", err)
}
return ctrl.Result{}, nil
}
jobName, err := r.Builder.Build(ctx, fn.Namespace, fn.Name, fn.Spec.S3Key)
if err != nil {
return r.setFailed(ctx, fn, fmt.Sprintf("failed to start build: %v", err))
+29 -1
View File
@@ -109,7 +109,8 @@ func (r *FunctionJobReconciler) Reconcile(ctx context.Context, req ctrl.Request)
return r.createRunJob(ctx, fj, deployNS, jobName)
}
// startJobBuild запускает kaniko сборку образа и переводит FunctionJob в фазу Building.
// startJobBuild проверяет наличие образа в registry и либо пропускает сборку,
// либо запускает kaniko Job. Идемпотентность по hash s3Key аналогична service/function.
func (r *FunctionJobReconciler) startJobBuild(ctx context.Context, fj *slessv1alpha1.FunctionJob) (ctrl.Result, error) {
logger := log.FromContext(ctx)
@@ -120,6 +121,33 @@ func (r *FunctionJobReconciler) startJobBuild(ctx context.Context, fj *slessv1al
return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
}
imageRef := r.Builder.ImageRef(r.OperatorNamespace, fj.Name, fj.Spec.S3Key)
// Проверяем: образ с этим тегом уже существует в registry?
// Если registry недоступен — requeue, не запускаем сборку.
exists, err := r.Builder.ImageExists(ctx, imageRef)
if err != nil {
return ctrl.Result{RequeueAfter: 10 * time.Second}, fmt.Errorf("check image exists: %w", err)
}
if exists {
logger.Info("image already exists in registry, skipping build", "imageRef", imageRef)
if fj.Annotations == nil {
fj.Annotations = map[string]string{}
}
if err := r.Update(ctx, fj); err != nil {
return ctrl.Result{}, fmt.Errorf("update annotations (cache hit): %w", err)
}
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseBuilding // перейдёт в run сразу
fj.Status.ImageRef = imageRef
fj.Status.Message = "Image restored from registry cache"
if err := r.Status().Update(ctx, fj); err != nil {
return ctrl.Result{}, fmt.Errorf("update status (cache hit): %w", err)
}
return ctrl.Result{Requeue: true}, nil
}
buildJobName, err := r.Builder.Build(ctx, r.OperatorNamespace, fj.Name, fj.Spec.S3Key)
if err != nil {
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseFailed
+33 -1
View File
@@ -101,8 +101,40 @@ func (r *ServiceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
return ctrl.Result{}, nil
}
// startServiceBuild запускает kaniko Job и помечает сервис как Building.
// startServiceBuild проверяет наличие образа в registry и либо пропускает сборку,
// либо запускает kaniko Job. Идемпотентность: если код не менялся (тег = hash s3Key),
// образ уже в registry → deploy без пересборки.
func (r *ServiceReconciler) startServiceBuild(ctx context.Context, svc *slessv1alpha1.Service) (ctrl.Result, error) {
imageRef := r.Builder.ImageRef(svc.Namespace, svc.Name, svc.Spec.S3Key)
// Проверяем: образ с этим тегом уже существует в registry?
// Если да — пропускаем kaniko, сразу переходим в Ready с известным imageRef.
// Если registry недоступен — requeue, не запускаем сборку (kaniko тоже упадёт).
exists, err := r.Builder.ImageExists(ctx, imageRef)
if err != nil {
return ctrl.Result{RequeueAfter: 10 * time.Second}, fmt.Errorf("check image exists: %w", err)
}
if exists {
logger := log.FromContext(ctx)
logger.Info("image already exists in registry, skipping build", "imageRef", imageRef)
if svc.Annotations == nil {
svc.Annotations = map[string]string{}
}
svc.Annotations["sless.kube5s.ru/last-built-s3key"] = svc.Spec.S3Key
if err := r.Update(ctx, svc); err != nil {
return ctrl.Result{}, fmt.Errorf("update service annotations (cache hit): %w", err)
}
svc.Status.Phase = slessv1alpha1.ServicePhaseReady
svc.Status.ImageRef = imageRef
svc.Status.Message = "Image restored from registry cache"
if err := r.Status().Update(ctx, svc); err != nil {
return ctrl.Result{}, fmt.Errorf("update service status (cache hit): %w", err)
}
return ctrl.Result{}, nil
}
jobName, err := r.Builder.Build(ctx, svc.Namespace, svc.Name, svc.Spec.S3Key)
if err != nil {
return r.setServiceFailed(ctx, svc, fmt.Sprintf("failed to start build: %v", err))
+62
View File
@@ -4,6 +4,68 @@
---
## 2026-03-22 — БАГ: CreateService/CreateFunction возвращает 409 при `terraform apply -replace` (ИСПРАВЛЕН)
### Симптом
```
terraform apply -replace=sless_service.pg_info
sless_service.pg_info: Destroying... [name=pg-info]
sless_service.pg_info: Destruction complete
sless_service.pg_info: Creating...
Error: create service: status 409: {"error":"service already exists"}
```
Все 22+ сервиса из `-replace` падают с 409 при пересоздании.
### Точная причина
**Цепочка событий** (воспроизводится только при наличии finalizer):
1. `terraform` вызывает `DELETE /services/pg-info`
2. API делает `h.K8s.Delete(svc)` → k8s **НЕ удаляет объект** немедленно.
Вместо этого — выставляет `DeletionTimestamp` на объекте и ждёт.
3. `service_controller.go` (асинхронно!) обрабатывает удаление:
- сносит Deployment, k8s Service, Ingress
- затем вызывает `svc.Finalizers = removeString(svc.Finalizers, serviceFinalizerName)`
- только после этого k8s реально удаляет CRD объект из etcd
- **латентность: 1–5 секунд**
4. `terraform` **немедленно** вызывает `POST /services/pg-info`
5. API делает `h.K8s.Create(svc)` → etcd возвращает `IsAlreadyExists`
6. Старый код проверял: `phase == Failed`? → нет, было `Ready` → `shouldRecreate=false` → **409**
**Ключевое: объект существует с `DeletionTimestamp != zero`, то есть он уже "мёртвый", но finalizer ещё не снят. Старый код этого не проверял.**
### Файл с багом
`internal/api/handler/services.go` → `CreateService()` — строка `shouldRecreate`
`internal/api/handler/functions.go` → `CreateFunction()` — аналогичная логика
### Исправление
В обоих файлах добавлена проверка `!existing.DeletionTimestamp.IsZero()` **до** проверки `shouldRecreate`:
```go
if getErr == nil && !existing.DeletionTimestamp.IsZero() {
// Объект удаляется: polling каждую секунду до 30 сек
for i := 0; i < 30; i++ {
time.Sleep(1 * time.Second)
if errors.IsNotFound(h.K8s.Get(...)) {
// исчez — создаём
}
}
// таймаут — возвращаем 409 с "try again later"
}
```
### Почему именно polling, а не Watch
`http.ResponseWriter` не поддерживает long-poll без дополнительного механизма.
Watch на объект внутри HTTP handler — антипаттерн (использует goroutine leak при отмене).
30 × 1s — достаточно для любого разумного кластера; terraform имеет свой retry.
---
## 2026-03-22 — ПОВЕДЕНИЕ: оператор выставляет Ready до готовности pod (known limitation)
### Симптом
+58 -1
View File
@@ -1,6 +1,63 @@
# Прогресс разработки
Последнее обновление: 2026-03-22
Последнее обновление: 2026-03-23
---
## 2026-03-23 — Сессия 11: Баги cache-тест, fix оператора v0.1.62, fix провайдера
### Что сделано
**Bug 1: Go 1.23 go.work + replace конфликт (FIXED → v0.1.61)**
- `internal/builder/context.go`: убран `replace sless/fn/handler => ./handler` из go.work шаблона
- Добавлен `sed` переименования модуля вместо replace
- Ошибка была: `go: workspace module sless/fn/handler is replaced at all versions in the go.work file`
**Bug 2: controller-runtime cache lag → 404 на upload (FIXED → v0.1.62)**
- `internal/api/handler/services.go`: retry loop 5×200ms в `UploadServiceCode`
- `internal/api/handler/jobs.go`: retry loop 5×200ms в `UploadJobCode`
- Причина: сразу после POST /services (201) informer cache ещё не синхронизирован → IsNotFound
**Bug 3: Провайдер — 409 при повторном apply после сбоя upload (FIXED)**
- `terraform/provider/internal/resources/service_resource.go`: в `Create()` при ошибке upload — rollback `DeleteService()`
- `terraform/provider/internal/resources/job_resource.go`: аналогично `DeleteJob()`
- Причина: upload в S3 падал (сетевой сбой), terraform не записывал state, CR оставался в k8s → следующий apply получал 409
**test_cache_matrix.sh v4**
- Убраны все `-target` из скрипта — terraform не должен касаться postgres при частичных операциях
- `destroy_sless_only()`: переименует `chaos_marathon.tf`, `functions.tf`, `stress.tf` → `.bak`, делает apply (terraform сам удаляет), возвращает файлы
- Phase 3a: закомментирует блоки ресурсов через Python вместо `-target`
**Operator v0.1.62** собран и задеплоен (`naeel/sless-operator:v0.1.62`)
### Статус
✅ v0.1.62 Running
✅ Провайдер пересобран на VM (`/tmp/sless-provider-dev/`)
⏳ test_cache_matrix.sh v4 — Phase 1 в процессе (pg-stats удалили вручную, apply идёт)
---
## 2026-03-23 — Сессия 10: ImageExists + деплой v0.1.58
### Что сделано
- **ImageExists** — новый метод `Builder.ImageExists(ctx, imageRef) bool` в `internal/builder/builder.go`
- Docker Registry v2 API: anonymous bearer token → HEAD `/v2/{repo}/manifests/{tag}`
- timeout 5s, fallback false при любой ошибке (безопасный fallback → kaniko)
- Приватные registry (Harbor и др.) → 401 → false → строим заново
- **service_controller.go** `startServiceBuild()`: кеш-проверка перед Build(); cache hit → `Status.Phase=Ready` + `Status.ImageRef` без kaniko
- **function_controller.go** `startBuild()`: аналогично
- **functionjob_controller.go** `startJobBuild()`: аналогично; cache hit → Requeue для немедленного перехода к run
- **v0.1.58** собран и задеплоен: `naeel/sless-operator:v0.1.58` Running, RESTARTS:0
### Статус
✅ Код написан (no errors)
✅ Docker build + push наDockerHub (sha256:7b0d48a01a29f2a5...)
✅ kubectl rollout: `deployment "sless-operator" successfully rolled out`
✅ Pod `sless-operator-85d4685c4b-6nxqw` Running v0.1.58
### Следующий шаг
Протестировать cache hit: вернуть `.tf.disabled` → `.tf`, `terraform apply` — образы уже на DockerHub, должны восстановиться без kaniko за секунды.
---
-301
View File
@@ -1,301 +0,0 @@
// 2026-03-21 — chaos_marathon.tf: 15 новых сервисов для часового хаос-марафона.
// Три рантайма: python3.11 (9), nodejs20 (2), go1.23 (2).
// Все зависят от sless_job.postgres_table_init_job.
# ── Python: работа с таблицей ─────────────────────────────────────────────────
# Считает строки по prefix — тест concurrent reads + COUNT агрегации.
resource "sless_service" "pg_counter" {
name = "pg-counter"
runtime = "python3.11"
entrypoint = "pg_counter.count"
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-counter"
depends_on = [sless_job.postgres_table_init_job]
}
# DELETE дублей по title — идемпотентный, повторный вызов безопасен.
resource "sless_service" "pg_dedup" {
name = "pg-dedup"
runtime = "python3.11"
entrypoint = "pg_dedup.dedup"
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/pg-dedup"
depends_on = [sless_job.postgres_table_init_job]
}
# Поиск по title с ILIKE + пагинация — тест спецсимволов и SQL injection safety.
resource "sless_service" "pg_search" {
name = "pg-search"
runtime = "python3.11"
entrypoint = "pg_search.search"
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-search"
depends_on = [sless_job.postgres_table_init_job]
}
# Bulk INSERT через execute_values — до 500 строк за раз.
resource "sless_service" "pg_bulk_insert" {
name = "pg-bulk-insert"
runtime = "python3.11"
entrypoint = "pg_bulk_insert.bulk_insert"
memory_mb = 256
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/pg-bulk-insert"
depends_on = [sless_job.postgres_table_init_job]
}
# DELETE строк старше N минут — идемпотентный.
resource "sless_service" "pg_delete_old" {
name = "pg-delete-old"
runtime = "python3.11"
entrypoint = "pg_delete_old.delete_old"
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/pg-delete-old"
depends_on = [sless_job.postgres_table_init_job]
}
# INSERT ON CONFLICT DO UPDATE — повторный вызов с тем же title безопасен.
resource "sless_service" "pg_upsert" {
name = "pg-upsert"
runtime = "python3.11"
entrypoint = "pg_upsert.upsert"
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-upsert"
depends_on = [sless_job.postgres_table_init_job]
}
# ── Python: chaos ─────────────────────────────────────────────────────────────
# Echo: принимает любой ввод и отражает обратно — проверка на мусорный input.
resource "sless_service" "chaos_echo" {
name = "chaos-echo"
runtime = "python3.11"
entrypoint = "chaos_echo.echo"
memory_mb = 128
timeout_sec = 10
source_dir = "${path.module}/code/chaos-echo"
depends_on = [sless_job.postgres_table_init_job]
}
# Валидация плохих параметров — тупой юзер не может уронить сервис.
resource "sless_service" "chaos_badparams" {
name = "chaos-badparams"
runtime = "python3.11"
entrypoint = "chaos_badparams.validate"
memory_mb = 128
timeout_sec = 10
source_dir = "${path.module}/code/chaos-badparams"
depends_on = [sless_job.postgres_table_init_job]
}
# Медленный pg_sleep — тест timeout enforcement.
resource "sless_service" "chaos_slowquery" {
name = "chaos-slowquery"
runtime = "python3.11"
entrypoint = "chaos_slowquery.slowquery"
memory_mb = 128
timeout_sec = 12
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/chaos-slowquery"
depends_on = [sless_job.postgres_table_init_job]
}
# Большой JSON response — тест памяти и серилизации.
resource "sless_service" "chaos_bigpayload" {
name = "chaos-bigpayload"
runtime = "python3.11"
entrypoint = "chaos_bigpayload.bigpayload"
memory_mb = 256
timeout_sec = 15
source_dir = "${path.module}/code/chaos-bigpayload"
depends_on = [sless_job.postgres_table_init_job]
}
# ── Node.js ───────────────────────────────────────────────────────────────────
# Bulk INSERT через параметризованный multi-value query.
resource "sless_service" "js_pg_batch" {
name = "js-pg-batch"
runtime = "nodejs20"
entrypoint = "js_pg_batch.run"
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/js-pg-batch"
depends_on = [sless_job.postgres_table_init_job]
}
# Идемпотентный INSERT — повторный вызов с тем же key = existing, не дубль.
resource "sless_service" "js_idempotent" {
name = "js-idempotent"
runtime = "nodejs20"
entrypoint = "js_idempotent.run"
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/js-idempotent"
depends_on = [sless_job.postgres_table_init_job]
}
# ── Go 1.23 ───────────────────────────────────────────────────────────────────
# Параллельные concurrent INSERTs из N горутин внутри одного пода.
resource "sless_service" "go_pg_race" {
name = "go-pg-race"
runtime = "go1.23"
entrypoint = "handler.Handle"
memory_mb = 256
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/go-pg-race"
depends_on = [sless_job.postgres_table_init_job]
}
# Atomic-счётчик в памяти + PG INSERT на каждый вызов.
resource "sless_service" "go_counter_atomic" {
name = "go-counter-atomic"
runtime = "go1.23"
entrypoint = "handler.Handle"
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/go-counter-atomic"
depends_on = [sless_job.postgres_table_init_job]
}
# ── Python: retry ─────────────────────────────────────────────────────────────
# Запись с retry при transient PG error — тест устойчивости к сбоям.
resource "sless_service" "py_retry_writer" {
name = "py-retry-writer"
runtime = "python3.11"
entrypoint = "py_retry_writer.retry_write"
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/py-retry-writer"
depends_on = [sless_job.postgres_table_init_job]
}
+116
View File
@@ -0,0 +1,116 @@
// Создано: 2026-04-10
// Простой калькулятор — возвращает HTML страницу с кнопками.
// GET → HTML; POST с {a, op, b} → вычисление с результатом на странице.
// Рантайм nodejs20 v0.1.3+ поддерживает HTML-ответ (строка начинающаяся с '<').
'use strict';
const HTML = `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Калькулятор (Node.js)</title>
<style>
body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #0f2027; }
.calc { background: #1a2a1a; border-radius: 16px; padding: 24px; box-shadow: 0 8px 32px rgba(0,0,0,0.5); width: 280px; }
h2 { color: #d4f7d4; text-align: center; margin: 0 0 16px; font-size: 18px; }
.badge { text-align: center; font-size: 11px; color: #4a7a4a; margin-bottom: 16px; }
.display { background: #0a1a0a; color: #e0ffe0; font-size: 28px; text-align: right; padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; min-height: 52px; word-break: break-all; }
.result { color: #4ade80; font-size: 20px; text-align: right; padding: 4px 16px; margin-bottom: 8px; min-height: 28px; }
.btns { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
button { border: none; border-radius: 8px; padding: 16px 0; font-size: 18px; cursor: pointer; transition: filter 0.1s; }
button:active { filter: brightness(1.3); }
.btn-num { background: #1e3a1e; color: #d4f7d4; }
.btn-op { background: #166534; color: #fff; }
.btn-eq { background: #15803d; color: #fff; grid-column: span 2; }
.btn-clr { background: #7f1d1d; color: #fca5a5; grid-column: span 2; }
.btn-zero { grid-column: span 2; }
</style>
</head>
<body>
<div class="calc">
<h2>Калькулятор</h2>
<div class="badge">Node.js 20</div>
<div id="display" class="display">0</div>
<div id="result" class="result">RESULT_LINE</div>
<div class="btns">
<button class="btn-clr btn-zero" onclick="clr()">C</button>
<button class="btn-op" onclick="setOp('%2F')">÷</button>
<button class="btn-op" onclick="setOp('*')">×</button>
<button class="btn-num" onclick="inp('7')">7</button>
<button class="btn-num" onclick="inp('8')">8</button>
<button class="btn-num" onclick="inp('9')">9</button>
<button class="btn-op" onclick="setOp('-')">−</button>
<button class="btn-num" onclick="inp('4')">4</button>
<button class="btn-num" onclick="inp('5')">5</button>
<button class="btn-num" onclick="inp('6')">6</button>
<button class="btn-op" onclick="setOp('+')">+</button>
<button class="btn-num" onclick="inp('1')">1</button>
<button class="btn-num" onclick="inp('2')">2</button>
<button class="btn-num" onclick="inp('3')">3</button>
<button class="btn-num btn-zero" onclick="inp('0')">0</button>
<button class="btn-num" onclick="inp('.')">.</button>
<button class="btn-eq" onclick="calc()">=</button>
</div>
</div>
<script>
let a = '', op = '', b = '';
const disp = document.getElementById('display');
function inp(v) {
if (op === '') { a += v; disp.textContent = a || '0'; }
else { b += v; disp.textContent = b || '0'; }
}
function setOp(o) {
if (a === '') return;
if (b !== '') calc();
else { op = o; disp.textContent = a + ' ' + decodeURIComponent(o) + ' …'; }
}
function clr() { a = ''; op = ''; b = ''; disp.textContent = '0'; }
function calc() {
if (a === '' || op === '' || b === '') return;
const form = document.createElement('form');
form.method = 'POST'; form.action = '';
const fields = {a, op, b};
for (const [k, v] of Object.entries(fields)) {
const i = document.createElement('input');
i.type = 'hidden'; i.name = k; i.value = decodeURIComponent(v);
form.appendChild(i);
}
document.body.appendChild(form);
form.submit();
}
</script>
</body>
</html>`;
// Форматирует число — убирает лишний .0 для целых
function fmt(n) { return n === Math.trunc(n) ? String(Math.trunc(n)) : String(n); }
function buildPage(resultLine) {
return HTML.replace('RESULT_LINE', resultLine || '');
}
function compute(event) {
// POST: тело приходит как JSON; поля a, op, b — строки из form
const a = parseFloat(event.a);
const b = parseFloat(event.b);
const op = event.op;
if (isNaN(a) || isNaN(b)) return buildPage('Некорректное число');
let res;
switch (op) {
case '+': res = a + b; break;
case '-': res = a - b; break;
case '*': res = a * b; break;
case '/':
if (b === 0) return buildPage('Деление на ноль');
res = a / b; break;
default: return buildPage('Неизвестный оператор');
}
return buildPage(`${fmt(a)} ${op} ${fmt(b)} = ${fmt(res)}`);
}
module.exports.handler = async function handler(event) {
if (event._method === 'POST') return compute(event);
return buildPage('');
};
@@ -0,0 +1,3 @@
{
"dependencies": {}
}
@@ -0,0 +1,120 @@
# Создано: 2026-04-10
# Простой калькулятор — возвращает HTML страницу с кнопками.
# Роутинг: GET → HTML страница; POST с {a, op, b} → вычисление и редирект с результатом.
# _method приходит от python3.11 рантайма в event.
_HTML = """<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Калькулятор (Python)</title>
<style>
body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #1a1a2e; }
.calc { background: #16213e; border-radius: 16px; padding: 24px; box-shadow: 0 8px 32px rgba(0,0,0,0.5); width: 280px; }
h2 { color: #e2e8f0; text-align: center; margin: 0 0 16px; font-size: 18px; }
.badge { text-align: center; font-size: 11px; color: #64748b; margin-bottom: 16px; }
.display { background: #0f172a; color: #f1f5f9; font-size: 28px; text-align: right; padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; min-height: 52px; word-break: break-all; }
.result { color: #22d3ee; font-size: 20px; text-align: right; padding: 4px 16px; margin-bottom: 8px; min-height: 28px; }
.btns { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
button { border: none; border-radius: 8px; padding: 16px 0; font-size: 18px; cursor: pointer; transition: filter 0.1s; }
button:active { filter: brightness(1.3); }
.btn-num { background: #334155; color: #f1f5f9; }
.btn-op { background: #0891b2; color: #fff; }
.btn-eq { background: #0d9488; color: #fff; grid-column: span 2; }
.btn-clr { background: #7f1d1d; color: #fca5a5; grid-column: span 2; }
.btn-zero { grid-column: span 2; }
</style>
</head>
<body>
<div class="calc">
<h2>Калькулятор</h2>
<div class="badge">Python 3.11</div>
<div id="display" class="display">0</div>
<div id="result" class="result">{result_line}</div>
<div class="btns">
<button class="btn-clr btn-zero" onclick="clr()">C</button>
<button class="btn-op" onclick="setOp('%2F')">÷</button>
<button class="btn-op" onclick="setOp('*')">×</button>
<button class="btn-num" onclick="inp('7')">7</button>
<button class="btn-num" onclick="inp('8')">8</button>
<button class="btn-num" onclick="inp('9')">9</button>
<button class="btn-op" onclick="setOp('-')">−</button>
<button class="btn-num" onclick="inp('4')">4</button>
<button class="btn-num" onclick="inp('5')">5</button>
<button class="btn-num" onclick="inp('6')">6</button>
<button class="btn-op" onclick="setOp('+')">+</button>
<button class="btn-num" onclick="inp('1')">1</button>
<button class="btn-num" onclick="inp('2')">2</button>
<button class="btn-num" onclick="inp('3')">3</button>
<button class="btn-num btn-zero" onclick="inp('0')">0</button>
<button class="btn-num" onclick="inp('.')">.</button>
<button class="btn-eq" onclick="calc()">=</button>
</div>
</div>
<script>
let a = '', op = '', b = '', fresh = {fresh_js};
const disp = document.getElementById('display');
function inp(v) {{
if (op === '') {{ a += v; disp.textContent = a || '0'; }}
else {{ b += v; disp.textContent = b || '0'; }}
fresh = false;
}}
function setOp(o) {{
if (a === '') return;
if (b !== '') calc();
else {{ op = o; disp.textContent = a + ' ' + decodeURIComponent(o) + ' …'; }}
fresh = false;
}}
function clr() {{ a = ''; op = ''; b = ''; disp.textContent = '0'; }}
function calc() {{
if (a === '' || op === '' || b === '') return;
const form = document.createElement('form');
form.method = 'POST'; form.action = '';
const fields = {{a, op, b}};
for (const [k, v] of Object.entries(fields)) {{
const i = document.createElement('input');
i.type = 'hidden'; i.name = k; i.value = decodeURIComponent(v);
form.appendChild(i);
}}
document.body.appendChild(form);
form.submit();
}}
</script>
</body>
</html>"""
def handler(event):
# POST: тело приходит как JSON от рантайма
if event.get('_method') == 'POST':
return _compute(event)
# GET: показываем чистую страницу
return _html()
def _html(result_line='', fresh_js='true'):
return _HTML.replace('{result_line}', result_line).replace('{fresh_js}', fresh_js)
def _compute(event):
try:
a = float(event.get('a', 0))
b = float(event.get('b', 0))
op = event.get('op', '')
if op == '+':
res = a + b
elif op == '-':
res = a - b
elif op == '*':
res = a * b
elif op == '/':
if b == 0:
return _html('Деление на ноль', 'false')
res = a / b
else:
return _html('Неизвестный оператор', 'false')
# Убираем лишний .0 для целых результатов
res_str = str(int(res)) if res == int(res) else str(res)
return _html(f'{int(a) if a == int(a) else a} {op} {int(b) if b == int(b) else b} = {res_str}', 'false')
except (ValueError, TypeError) as exc:
return _html(f'Ошибка: {exc}', 'false')
@@ -0,0 +1 @@
# нет внешних зависимостей
@@ -1,40 +0,0 @@
# 2026-03-21 — chaos-badparams: проверяет что функция не падает на мусорных входных данных.
# Принимает type=missing|wrong_type|huge|negative|zero и возвращает safe-ответ.
# Тестирует: устойчивость к "тупому юзеру" — никакого 500 на плохих входных данных.
import json
_MAX_N = 10_000
def validate(event):
errors = []
results = {}
# n: должно быть int от 1 до MAX_N
raw_n = event.get("n")
try:
n = int(raw_n)
if n <= 0:
errors.append(f"n must be > 0, got {n}")
n = 1
elif n > _MAX_N:
errors.append(f"n capped from {n} to {_MAX_N}")
n = _MAX_N
except (TypeError, ValueError):
errors.append(f"n is not a valid int: {repr(raw_n)}, using default 1")
n = 1
results["n"] = n
# name: обрезаем до 100 символов
raw_name = event.get("name", "")
if not isinstance(raw_name, str):
raw_name = str(raw_name)
errors.append("name was not a string, converted")
name = raw_name[:100]
results["name"] = name
# flag: любое "truthy" значение
raw_flag = event.get("flag", False)
flag = raw_flag in (True, "true", "1", 1, "yes")
results["flag"] = flag
return {"ok": len(errors) == 0, "errors": errors, "results": results}
@@ -1,27 +0,0 @@
# 2026-03-21 — chaos-bigpayload: генерирует/принимает большой JSON.
# Тестирует: большие ответы (64KB+), память рантайма.
import json, time
def bigpayload(event):
size_kb = min(int(event.get("size_kb", 16)), 256) # cap 256KB
word = str(event.get("word", "x"))[:32]
# Генерируем список строк нужного размера
chunk = word * 32 # ~32+ байт на запись
items = []
total = 0
target = size_kb * 1024
i = 0
while total < target:
entry = f"{chunk}-{i}"
items.append(entry)
total += len(entry) + 3 # 3 байта JSON overhead
i += 1
return {
"items_count": len(items),
"size_kb_approx": round(total / 1024, 1),
"first": items[0] if items else "",
"last": items[-1] if items else "",
"ts": int(time.time()),
}
@@ -1,19 +0,0 @@
# 2026-03-21 — chaos-echo: отражает входные данные обратно.
# Тестирует: большие payload, unicode, null, вложенные структуры, спецсимволы.
# "Тупой юзер" шлёт всё что угодно — функция должна вернуть это обратно без падения.
import json
def echo(event):
# Пытаемся сериализовать обратно — выловит непериализуемые типы
try:
size = len(json.dumps(event))
except Exception:
size = -1
keys = list(event.keys()) if isinstance(event, dict) else []
return {
"echo": event,
"keys": keys,
"size_bytes": size,
"type": type(event).__name__,
}
@@ -1,19 +0,0 @@
# 2026-03-21 — chaos-slowquery: намеренно медленный запрос через pg_sleep.
# Тестирует: timeout enforcement — платформа должна прервать запрос если > timeout_sec.
# sleep_sec cap = 8 (меньше timeout_sec=10 сервиса → успех; >10 → таймаут платформы).
import os, psycopg2
def slowquery(event):
sleep_sec = min(float(event.get("sleep_sec", 2.0)), 8.0)
conn = psycopg2.connect(
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
)
try:
with conn.cursor() as cur:
cur.execute("SELECT pg_sleep(%s), now()::text", (sleep_sec,))
result = cur.fetchone()
return {"slept_sec": sleep_sec, "pg_now": result[1]}
finally:
conn.close()
@@ -1 +0,0 @@
psycopg2-binary
@@ -1,94 +0,0 @@
# 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"
@@ -1 +0,0 @@
requests==2.31.0
@@ -1,55 +0,0 @@
// 2026-03-21 — go-counter-atomic: считает вызовы через atomic в памяти + пишет в PG.
// Тестирует: in-memory state между вызовами (Go pod остаётся живым), + PG INSERT на каждый вызов.
package handler
import (
"context"
"fmt"
"os"
"sync/atomic"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// invocations считается между вызовами (пока pod жив).
var invocations int64
// Handle записывает факт вызова в PG и возвращает накопленный счётчик.
func Handle(event map[string]interface{}) interface{} {
n := atomic.AddInt64(&invocations, 1)
dsn := fmt.Sprintf(
"host=%s port=%s dbname=%s user=%s password=%s sslmode=%s",
os.Getenv("PGHOST"), envOrDefault("PGPORT", "5432"),
os.Getenv("PGDATABASE"), os.Getenv("PGUSER"),
os.Getenv("PGPASSWORD"), envOrDefault("PGSSLMODE", "require"),
)
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
return map[string]interface{}{"invocation_n": n, "error": err.Error()}
}
defer pool.Close()
title := fmt.Sprintf("go-counter-invoke-%d-%d", n, time.Now().UnixMilli())
var id int64
err = pool.QueryRow(context.Background(),
"INSERT INTO terraform_demo_table (title) VALUES ($1) RETURNING id", title,
).Scan(&id)
if err != nil {
return map[string]interface{}{"invocation_n": n, "error": err.Error()}
}
return map[string]interface{}{
"invocation_n": n,
"inserted_id": id,
"title": title,
}
}
func envOrDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
@@ -1,94 +0,0 @@
// 2026-03-21 — go-pg-race: параллельные INSERT из нескольких горутин внутри одной функции.
// Тестирует: race condition устойчивость Go + PG при concurrent writes из одного пода.
// Использует pgx/v5 (pre-cached в base image).
package handler
import (
"context"
"fmt"
"os"
"sync"
"sync/atomic"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Handle запускает workers горутин, каждая делает n_per_worker INSERTs.
func Handle(event map[string]interface{}) interface{} {
workers := intParam(event, "workers", 5)
if workers > 20 {
workers = 20
}
nPerWorker := intParam(event, "n_per_worker", 10)
if nPerWorker > 50 {
nPerWorker = 50
}
dsn := fmt.Sprintf(
"host=%s port=%s dbname=%s user=%s password=%s sslmode=%s",
os.Getenv("PGHOST"), getenv("PGPORT", "5432"),
os.Getenv("PGDATABASE"), os.Getenv("PGUSER"),
os.Getenv("PGPASSWORD"), getenv("PGSSLMODE", "require"),
)
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
return map[string]interface{}{"error": err.Error()}
}
defer pool.Close()
var (
wg sync.WaitGroup
ok int64
errCount int64
)
t0 := time.Now()
for w := 0; w < workers; w++ {
wg.Add(1)
go func(wid int) {
defer wg.Done()
for i := 0; i < nPerWorker; i++ {
title := fmt.Sprintf("go-race-w%d-%d-%d", wid, time.Now().UnixMilli(), i)
_, err := pool.Exec(context.Background(),
"INSERT INTO terraform_demo_table (title) VALUES ($1)", title)
if err != nil {
atomic.AddInt64(&errCount, 1)
} else {
atomic.AddInt64(&ok, 1)
}
}
}(w)
}
wg.Wait()
elapsed := time.Since(t0).Seconds()
return map[string]interface{}{
"workers": workers,
"n_per_worker": nPerWorker,
"inserted": ok,
"errors": errCount,
"elapsed_sec": elapsed,
"ops_per_sec": float64(ok) / elapsed,
}
}
func intParam(event map[string]interface{}, key string, def int) int {
v, ok := event[key]
if !ok {
return def
}
switch val := v.(type) {
case float64:
return int(val)
case int:
return val
}
return def
}
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
@@ -1,43 +0,0 @@
// 2026-03-21 — js-pg-batch: вставляет N строк через parameterized bulk query.
// Тестирует: async/await PG с пакетной вставкой, Node.js под нагрузкой.
const { Client } = require('pg');
async function run(event) {
const n = Math.min(parseInt(event.n ?? 20, 10) || 20, 200);
const prefix = String(event.prefix ?? 'js-batch').slice(0, 40);
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,
ssl: { rejectUnauthorized: false },
});
await client.connect();
try {
const ts = Date.now();
// Строим multi-value INSERT: INSERT INTO ... VALUES ($1), ($2), ...
const placeholders = [];
const values = [];
for (let i = 0; i < n; i++) {
placeholders.push(`($${i + 1})`);
values.push(`${prefix}-${ts}-${i}`);
}
const sql = `INSERT INTO terraform_demo_table (title) VALUES ${placeholders.join(',')} RETURNING id`;
const t0 = Date.now();
const res = await client.query(sql, values);
const elapsed = (Date.now() - t0) / 1000;
return {
inserted: res.rowCount,
first_id: res.rows[0]?.id ?? null,
elapsed_sec: elapsed,
};
} finally {
await client.end();
}
}
module.exports = { run };
@@ -1,7 +0,0 @@
{
"name": "js-pg-batch",
"version": "1.0.0",
"dependencies": {
"pg": "^8.11.3"
}
}
@@ -1,38 +0,0 @@
# 2026-03-21 — pg-bulk-insert: bulk INSERT через execute_values.
# Тестирует: большие батчи (до 500 строк), производительность, память.
import os, time, psycopg2, psycopg2.extras
def bulk_insert(event):
try:
n = max(0, min(int(event.get("n", 50)), 500)) # cap 500, min 0
except (TypeError, ValueError):
n = 50
prefix = str(event.get("prefix", "bulk"))[:50]
ts = int(time.time() * 1000)
# n=0 — граничный случай: вернуть сразу без обращения к PG.
if n == 0:
return {"inserted": 0, "first_id": None, "elapsed_sec": 0.0}
rows = [(f"{prefix}-{ts}-{i}",) for i in range(n)]
conn = psycopg2.connect(
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
)
try:
t0 = time.time()
with conn.cursor() as cur:
psycopg2.extras.execute_values(
cur,
"INSERT INTO terraform_demo_table (title) VALUES %s RETURNING id",
rows,
page_size=100,
)
ids = [r[0] for r in cur.fetchall()]
conn.commit()
elapsed = round(time.time() - t0, 3)
return {"inserted": len(ids), "first_id": ids[0] if ids else None, "elapsed_sec": elapsed}
finally:
conn.close()
@@ -1 +0,0 @@
psycopg2-binary
@@ -1,38 +0,0 @@
# 2026-03-21 — pg-dedup: удаляет дубликаты по title, оставляет первый (min id).
# Тестирует: DELETE с subquery, CTE, idempotency (повторный вызов безопасен).
import os, psycopg2
def dedup(event):
dry_run = str(event.get("dry_run", "false")).lower() in ("true", "1", "yes")
conn = psycopg2.connect(
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
)
try:
with conn.cursor() as cur:
# Считаем сколько дублей есть
cur.execute("""
SELECT COUNT(*) FROM terraform_demo_table t1
WHERE EXISTS (
SELECT 1 FROM terraform_demo_table t2
WHERE t2.title = t1.title AND t2.id < t1.id
)
""")
dupes_count = cur.fetchone()[0]
if not dry_run and dupes_count > 0:
cur.execute("""
DELETE FROM terraform_demo_table
WHERE id NOT IN (
SELECT MIN(id) FROM terraform_demo_table GROUP BY title
)
""")
deleted = cur.rowcount
conn.commit()
else:
deleted = 0
return {"duplicates_found": dupes_count, "deleted": deleted, "dry_run": dry_run}
finally:
conn.close()
@@ -1 +0,0 @@
psycopg2-binary
@@ -1,33 +0,0 @@
# 2026-03-21 — pg-delete-old: удаляет строки старше N минут (default 60).
# Тестирует: DELETE с RETURNING, идемпотентность (повторный вызов = 0 удалений если нет старых).
import os, psycopg2, psycopg2.extras
def delete_old(event):
older_than_min = max(int(event.get("older_than_min", 60)), 1)
prefix_filter = event.get("prefix", "")
conn = psycopg2.connect(
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
)
try:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
if prefix_filter:
cur.execute(
"DELETE FROM terraform_demo_table "
"WHERE created_at < now() - interval '1 minute' * %s "
"AND title LIKE %s RETURNING id, title",
(older_than_min, f"{prefix_filter}%"),
)
else:
cur.execute(
"DELETE FROM terraform_demo_table "
"WHERE created_at < now() - interval '1 minute' * %s RETURNING id, title",
(older_than_min,),
)
deleted = [dict(r) for r in cur.fetchall()]
conn.commit()
return {"deleted": len(deleted), "older_than_min": older_than_min, "sample": deleted[:5]}
finally:
conn.close()
@@ -1 +0,0 @@
psycopg2-binary
@@ -1,36 +0,0 @@
# 2026-03-21 — pg-search: полнотекстовый поиск по title через ILIKE + LIMIT/OFFSET.
# Тестирует: пагинацию, спецсимволы в input (XSS, SQL injection attempt → безопасно через параметры).
import os, psycopg2, psycopg2.extras
def search(event):
# «query» — основной параметр (user-friendly), «q» — алиас для совместимости.
query = str(event.get("query") or event.get("q") or "")[:200]
# int() может упасть если юзер прислал строку — защищаем try/except.
try:
limit = max(1, min(int(event.get("limit", 20)), 100))
except (TypeError, ValueError):
limit = 20
try:
offset = max(0, int(event.get("offset", 0)))
except (TypeError, ValueError):
offset = 0
conn = psycopg2.connect(
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
)
try:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
pattern = f"%{query}%" if query else "%"
cur.execute(
"SELECT id, title, created_at::text FROM terraform_demo_table "
"WHERE title ILIKE %s ORDER BY id DESC LIMIT %s OFFSET %s",
(pattern, limit, offset),
)
rows = [dict(r) for r in cur.fetchall()]
cur.execute("SELECT COUNT(*) FROM terraform_demo_table WHERE title ILIKE %s", (pattern,))
total = cur.fetchone()["count"]
return {"rows": rows, "count": len(rows), "total": total, "q": query, "limit": limit, "offset": offset}
finally:
conn.close()
@@ -1 +0,0 @@
psycopg2-binary
@@ -1,36 +0,0 @@
# 2026-03-21 — pg-upsert: INSERT ... ON CONFLICT (title) DO UPDATE.
# Тестирует: идемпотентность вставки — один и тот же title можно вызывать 100 раз подряд.
# Требует уникального индекса на title — создаётся при первом вызове (CREATE UNIQUE INDEX IF NOT EXISTS).
import os, psycopg2
def upsert(event):
title = str(event.get("title", "upsert-default"))[:255]
payload = str(event.get("payload", ""))[:500]
conn = psycopg2.connect(
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
)
try:
with conn.cursor() as cur:
# Создаём уникальный индекс если нет — для поддержки ON CONFLICT
cur.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS terraform_demo_table_title_uniq "
"ON terraform_demo_table (title)"
)
cur.execute(
"INSERT INTO terraform_demo_table (title) VALUES (%s) "
"ON CONFLICT (title) DO UPDATE SET created_at = now() "
"RETURNING id, title, created_at::text, xmax",
(title,),
)
row = cur.fetchone()
was_insert = row[3] == 0 # xmax=0 означает INSERT, иначе UPDATE
conn.commit()
return {
"id": row[0], "title": row[1], "created_at": row[2],
"action": "inserted" if was_insert else "updated",
}
finally:
conn.close()
@@ -1 +0,0 @@
psycopg2-binary
@@ -1,54 +0,0 @@
# 2026-03-21 — py-retry-writer: пишет N строк с retry при PG ошибке.
# Тестирует: устойчивость к transient PG errors (simulate_error=true), retry logic,
# корректный rollback при частичном сбое.
import os, time, psycopg2, random
_MAX_RETRIES = 3
def retry_write(event):
n = min(int(event.get("n", 5)), 100)
prefix = str(event.get("prefix", "retry"))[:40]
# simulate_error: с вероятностью 30% кидает OperationalError на 2-й попытке
simulate = str(event.get("simulate_error", "false")).lower() in ("true", "1")
attempt = 0
last_err = None
while attempt < _MAX_RETRIES:
attempt += 1
try:
conn = psycopg2.connect(
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
)
inserted = []
try:
with conn.cursor() as cur:
for i in range(n):
# Симуляция: на первой попытке падаем с вероятностью 50%
if simulate and attempt == 1 and i == n // 2:
raise psycopg2.OperationalError("simulated transient error")
title = f"{prefix}-{int(time.time()*1000)}-{i}-a{attempt}"
cur.execute(
"INSERT INTO terraform_demo_table (title) VALUES (%s) RETURNING id",
(title,),
)
inserted.append(cur.fetchone()[0])
conn.commit()
return {
"ok": True, "inserted": len(inserted),
"attempts": attempt, "first_id": inserted[0] if inserted else None,
}
except Exception as e:
conn.rollback()
raise
finally:
conn.close()
except psycopg2.OperationalError as e:
last_err = str(e)
if attempt < _MAX_RETRIES:
time.sleep(0.3 * attempt) # exponential backoff
continue
return {"ok": False, "attempts": attempt, "last_error": last_err}
@@ -1 +0,0 @@
psycopg2-binary
@@ -1,3 +0,0 @@
# 2026-03-17 00:00
# requirements.txt — зависимости для функции запуска SQL.
psycopg2-binary==2.9.9
@@ -1,39 +0,0 @@
# 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()
@@ -1,20 +0,0 @@
# 2026-03-19
# stress_bigloop.py — CPU-интенсивная функция: считает сумму квадратов N чисел.
# Проверяет поведение под нагрузкой (большая и средняя итерация).
import time
_VERSION = "v1"
def run(event):
n = int(event.get("n", 500_000))
start = time.monotonic()
total = sum(i * i for i in range(n))
elapsed = round(time.monotonic() - start, 4)
return {
"version": _VERSION,
"n": n,
"sum_of_squares": total,
"elapsed_sec": elapsed,
}
@@ -1,13 +0,0 @@
# 2026-03-19
# stress_divzero.py — намеренно делит на ноль (ZeroDivisionError).
# Проверяет: платформа перехватывает панику, возвращает HTTP 500, не роняет под.
_VERSION = "v1"
def run(event):
numerator = int(event.get("n", 42))
denominator = int(event.get("d", 0)) # по умолчанию 0 — намеренный краш
# ZeroDivisionError: проверяем что платформа обрабатывает исключения
result = numerator / denominator
return {"version": _VERSION, "result": result}
@@ -1,42 +0,0 @@
// 2026-03-19
// handler.go — быстрая Go функция: факториал + числа Фибоначчи.
// Проверяет Go runtime под лёгкой нагрузкой и корректность JSON-ответа.
// Entrypoint: handler.Handle
package handler
import "fmt"
func factorial(n int) uint64 {
if n <= 1 {
return 1
}
return uint64(n) * factorial(n-1)
}
func fib(n int) int {
if n <= 1 {
return n
}
a, b := 0, 1
for i := 2; i <= n; i++ {
a, b = b, a+b
}
return b
}
func Handle(event map[string]interface{}) interface{} {
n := 10
if v, ok := event["n"].(float64); ok {
n = int(v)
if n > 20 {
n = 20
}
}
return map[string]interface{}{
"runtime": "go1.23",
"version": "v1",
"n": n,
"factorial": fmt.Sprintf("%d", factorial(n)),
"fib": fib(n),
}
}
@@ -1,21 +0,0 @@
// 2026-03-19
// handler.go — намеренный nil pointer dereference в Go.
// Проверяет что Go runtime recover() перехватывает панику и платформа возвращает 500.
// Entrypoint: handler.Handle
package handler
func Handle(event map[string]interface{}) interface{} {
crash := true
if v, ok := event["crash"].(bool); ok {
crash = v
}
if crash {
var p *string
_ = *p // panic: намеренный nil pointer для stress-теста
}
return map[string]interface{}{
"runtime": "go1.23",
"version": "v1",
"crashed": false,
}
}
@@ -1,148 +0,0 @@
// 2026-03-19
// handler.go — Go стресс-тест PostgreSQL через pgxpool.
// Запускает N горутин (default 100), каждая в цикле duration_sec (default 600)
// долбит PG попеременно: INSERT / SELECT COUNT / SELECT MAX с случайными задержками.
// Цель: проверить Go runtime под конкурентной нагрузкой и устойчивость PG connection pool.
// Entrypoint: handler.Handle
package handler
import (
"context"
"fmt"
"math/rand"
"os"
"sync"
"sync/atomic"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// pgDSN собирает DSN из env vars (PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, PGSSLMODE).
func pgDSN() string {
host := os.Getenv("PGHOST")
port := os.Getenv("PGPORT")
if port == "" {
port = "5432"
}
db := os.Getenv("PGDATABASE")
user := os.Getenv("PGUSER")
pass := os.Getenv("PGPASSWORD")
sslmode := os.Getenv("PGSSLMODE")
if sslmode == "" {
sslmode = "require"
}
return fmt.Sprintf("host=%s port=%s dbname=%s user=%s password=%s sslmode=%s",
host, port, db, user, pass, sslmode)
}
// worker — одна горутина: чередует INSERT/COUNT/MAX с случайной задержкой до maxDelayMs.
// При ошибке инкрементирует errOps и продолжает (не паникует).
func worker(ctx context.Context, pool *pgxpool.Pool, workerID int, maxDelayMs int, okOps, errOps *int64) {
rng := rand.New(rand.NewSource(time.Now().UnixNano() + int64(workerID)))
op := 0
for {
select {
case <-ctx.Done():
return
default:
}
// Случайная задержка перед следующей операцией: 0..maxDelayMs мс
delay := rng.Intn(maxDelayMs + 1)
time.Sleep(time.Duration(delay) * time.Millisecond)
var err error
switch op % 3 {
case 0: // INSERT
title := fmt.Sprintf("pgstorm-w%d-%d", workerID, time.Now().UnixNano())
_, err = pool.Exec(ctx,
"INSERT INTO terraform_demo_table (title) VALUES ($1)", title)
case 1: // SELECT COUNT
var count int64
err = pool.QueryRow(ctx,
"SELECT COUNT(*) FROM terraform_demo_table").Scan(&count)
case 2: // SELECT MAX id
var maxID *int64
err = pool.QueryRow(ctx,
"SELECT MAX(id) FROM terraform_demo_table").Scan(&maxID)
}
if err != nil && ctx.Err() == nil {
atomic.AddInt64(errOps, 1)
} else if err == nil {
atomic.AddInt64(okOps, 1)
}
op++
}
}
func Handle(event map[string]interface{}) interface{} {
// Параметры из event (все опциональны — разумные defaults)
workers := 100
if v, ok := event["workers"].(float64); ok && v > 0 && v <= 500 {
workers = int(v)
}
durationSec := 600
if v, ok := event["duration_sec"].(float64); ok && v > 0 && v <= 3600 {
durationSec = int(v)
}
maxDelayMs := 300
if v, ok := event["max_delay_ms"].(float64); ok && v >= 0 && v <= 5000 {
maxDelayMs = int(v)
}
// Инициализация pgxpool — единый pool на всю функцию, MaxConns ограничен
// чтобы не перегрузить managed PG при большом числе горутин.
poolCfg, err := pgxpool.ParseConfig(pgDSN())
if err != nil {
return map[string]interface{}{"error": fmt.Sprintf("parse dsn: %v", err)}
}
maxConns := 20
if workers < 20 {
maxConns = workers
}
poolCfg.MaxConns = int32(maxConns)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(durationSec)*time.Second)
defer cancel()
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
if err != nil {
return map[string]interface{}{"error": fmt.Sprintf("connect pool: %v", err)}
}
defer pool.Close()
var okOps, errOps int64
startTime := time.Now()
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
worker(ctx, pool, id, maxDelayMs, &okOps, &errOps)
}(i)
}
wg.Wait()
elapsed := time.Since(startTime).Seconds()
total := okOps + errOps
opsPerSec := 0.0
if elapsed > 0 {
opsPerSec = float64(total) / elapsed
}
return map[string]interface{}{
"runtime": "go1.23",
"version": "v1",
"workers": workers,
"duration_sec": durationSec,
"max_delay_ms": maxDelayMs,
"elapsed_sec": fmt.Sprintf("%.1f", elapsed),
"total_ops": total,
"ok_ops": okOps,
"err_ops": errOps,
"ops_per_sec": fmt.Sprintf("%.1f", opsPerSec),
}
}
@@ -1,7 +0,0 @@
{
"name": "stress-js-async",
"version": "1.0.0",
"dependencies": {
"pg": "^8.11.0"
}
}
@@ -1,37 +0,0 @@
// 2026-03-19
// stress_js_async.js — делает 3 параллельных запроса к PG через Promise.all.
// Проверяет nodejs20 runtime под умеренной нагрузкой и async/await.
//
// Entrypoint: stress_js_async.run
'use strict';
const { Client } = require('pg');
exports.run = 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,
ssl: process.env.PGSSLMODE === 'require' ? { rejectUnauthorized: false } : false,
});
await client.connect();
try {
const [ver, cnt, max] = await Promise.all([
client.query('SELECT version() AS v'),
client.query('SELECT COUNT(*) AS cnt FROM terraform_demo_table'),
client.query('SELECT MAX(id) AS max_id FROM terraform_demo_table'),
]);
return {
runtime: 'nodejs20',
version: 'v1',
pg_version: ver.rows[0].v.split(' ').slice(0, 2).join(' '),
total_rows: parseInt(cnt.rows[0].cnt, 10),
max_id: max.rows[0].max_id,
};
} finally {
await client.end();
}
};
@@ -1,5 +0,0 @@
{
"name": "stress-js-badenv",
"version": "1.0.0",
"dependencies": {}
}
@@ -1,17 +0,0 @@
// 2026-03-19
// stress_js_badenv.js — читает несуществующую переменную env и падает.
// Проверяет: платформа перехватывает TypeError/undefined, возвращает 500.
//
// Entrypoint: stress_js_badenv.run
'use strict';
exports.run = async (event) => {
const crash = event.crash !== false; // по умолчанию crash=true
if (crash) {
// Читаем несуществующий env, пытаемся вызвать .toUpperCase() на undefined
const val = process.env.THIS_VAR_DOES_NOT_EXIST_AT_ALL;
return { shout: val.toUpperCase() }; // TypeError: Cannot read properties of undefined
}
return { runtime: 'nodejs20', version: 'v1', crashed: false };
};
@@ -1,18 +0,0 @@
# 2026-03-19
# stress_slow.py — долгая функция: спит N секунд (по умолчанию 8).
# Проверяет что timeout-механизм и параллельные запросы не блокируют друг друга.
import time
import os
_VERSION = "v1"
def run(event):
secs = int(event.get("sleep", 8))
time.sleep(secs)
return {
"version": _VERSION,
"slept_sec": secs,
"pid": os.getpid(),
}
@@ -1 +0,0 @@
psycopg2-binary==2.9.9
@@ -1,39 +0,0 @@
# 2026-03-19
# stress_writer.py — пишет N строк в terraform_demo_table (по умолчанию 5).
# Проверяет параллельные INSERT'ы и устойчивость соединения с PG при нагрузке.
import os
import psycopg2
import time
_VERSION = "v1"
def run(event):
n = int(event.get("rows", 5))
prefix = event.get("prefix", "stress")
conn = psycopg2.connect(
host=os.environ["PGHOST"],
port=int(os.environ.get("PGPORT", "5432")),
dbname=os.environ["PGDATABASE"],
user=os.environ["PGUSER"],
password=os.environ["PGPASSWORD"],
sslmode=os.environ.get("PGSSLMODE", "require"),
)
inserted = []
try:
with conn.cursor() as cur:
for i in range(n):
title = f"{prefix}-{int(time.time()*1000)}-{i}"
cur.execute(
"INSERT INTO terraform_demo_table (title) VALUES (%s) RETURNING id",
(title,),
)
row = cur.fetchone()
inserted.append({"id": row[0], "title": title})
conn.commit()
finally:
conn.close()
return {"version": _VERSION, "inserted": inserted, "count": len(inserted)}
@@ -1 +0,0 @@
psycopg2-binary==2.9.9
-133
View File
@@ -1,133 +0,0 @@
# 2026-03-19 — добавлен version и hostname в ответ list_rows для тестирования обновления кода
# 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 socket
import psycopg2
import psycopg2.extras
_CODE_VERSION = "v2-with-hostname"
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), "version": _CODE_VERSION, "host": socket.gethostname()}
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()
+22 -95
View File
@@ -1,105 +1,32 @@
// 2026-03-20 (merge: sless_function + старый sless_job объединены в один self-contained sless_job)
// Теперь sless_job несёт в себе runtime/entrypoint/source_dir — не нужен отдельный sless_function.
// WaitJobDone таймаут 900s покрывает kaniko сборку (~5 мин) + выполнение SQL (~несколько сек).
# Создано: 2026-04-10
# functions.tf — sless_service ресурсы для примера POSTGRES.
# Здесь: два калькуляторa — Python и Node.js.
# sless_service = long-running Deployment + постоянный URL (в отличие от sless_function).
# Одноразовый запуск: собирает образ через kaniko, выполняет SQL, завершается.
# Заменяет sless_function.postgres_sql_runner_create_table + sless_job.postgres_table_init_job.
resource "sless_job" "postgres_table_init_job" {
name = "pg-create-table-job-main-v13"
runtime = "python3.11"
entrypoint = "sql_runner.run_sql"
memory_mb = 128
timeout_sec = 30
source_dir = "${path.module}/code/sql-runner"
wait_timeout_sec = 900
run_id = 13
# ─── Python-калькулятор ──────────────────────────────────────────────────────
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
}
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]
resource "sless_service" "calc_python" {
name = "calc-python"
runtime = "python3.11"
entrypoint = "handler.handler"
source_dir = "${path.module}/code/calc-python"
}
# Long-running сервис на NodeJS: возвращает версию PG-сервера и счётчик строк в таблице.
resource "sless_service" "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]
output "calc_python_url" {
description = "URL Python-калькулятора"
value = sless_service.calc_python.url
}
resource "sless_service" "postgres_table_reader" {
name = "pg-table-reader"
runtime = "python3.11"
entrypoint = "table_rw.list_rows"
memory_mb = 128
timeout_sec = 30
# ─── Node.js-калькулятор ─────────────────────────────────────────────────────
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_service" "calc_node" {
name = "calc-node"
runtime = "nodejs20"
entrypoint = "handler.handler"
source_dir = "${path.module}/code/calc-node"
}
output "table_reader_url" {
value = sless_service.postgres_table_reader.url
}
resource "sless_service" "postgres_table_writer" {
name = "pg-table-writer"
runtime = "python3.11"
entrypoint = "table_rw.add_row"
memory_mb = 256
timeout_sec = 45
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]
}
output "table_writer_url" {
value = sless_service.postgres_table_writer.url
output "calc_node_url" {
description = "URL Node.js-калькулятора"
value = sless_service.calc_node.url
}
-167
View File
@@ -1,167 +0,0 @@
// 2026-03-21 — stress.tf: все стресс-сервисы для комплексного тестирования.
// Три рантайма: go1.23 (3), nodejs20 (2), python3.11 (5).
// Все depends_on = [sless_job.postgres_table_init_job] — таблица должна существовать.
# ── Go 1.23 ─────────────────────────────────────────────────────────────────
# Быстрая математика: факториал + числа Фибоначчи. Без PG. Проверяет Go runtime.
resource "sless_service" "stress_go_fast" {
name = "stress-go-fast"
runtime = "go1.23"
entrypoint = "handler.Handle"
memory_mb = 128
timeout_sec = 15
source_dir = "${path.module}/code/stress-go-fast"
depends_on = [sless_job.postgres_table_init_job]
}
# Намеренный nil pointer dereference. Без PG. Проверяет recover() в Go runtime.
resource "sless_service" "stress_go_nil" {
name = "stress-go-nil"
runtime = "go1.23"
entrypoint = "handler.Handle"
memory_mb = 128
timeout_sec = 10
source_dir = "${path.module}/code/stress-go-nil"
depends_on = [sless_job.postgres_table_init_job]
}
# Конкурентный PG-шторм через pgxpool: N горутин INSERT/COUNT/MAX.
# timeout_sec=660 — покрывает max duration_sec=600 с запасом.
# pgx/v5 уже в базовом образе go1.23: user go.mod не нужен.
resource "sless_service" "stress_go_pgstorm" {
name = "stress-go-pgstorm"
runtime = "go1.23"
entrypoint = "handler.Handle"
memory_mb = 256
timeout_sec = 660
source_dir = "${path.module}/code/stress-go-pgstorm"
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
}
depends_on = [sless_job.postgres_table_init_job]
}
# ── Node.js 20 ────────────────────────────────────────────────────────────────
# 3 параллельных PG-запроса через Promise.all. Проверяет async/await + nodejs20.
resource "sless_service" "stress_js_async" {
name = "stress-js-async"
runtime = "nodejs20"
entrypoint = "stress_js_async.run"
memory_mb = 128
timeout_sec = 20
source_dir = "${path.module}/code/stress-js-async"
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
}
depends_on = [sless_job.postgres_table_init_job]
}
# TypeError через undefined.toUpperCase(). Без PG. Проверяет перехват JS-ошибок.
resource "sless_service" "stress_js_badenv" {
name = "stress-js-badenv"
runtime = "nodejs20"
entrypoint = "stress_js_badenv.run"
memory_mb = 128
timeout_sec = 10
source_dir = "${path.module}/code/stress-js-badenv"
depends_on = [sless_job.postgres_table_init_job]
}
# ── Python 3.11 ───────────────────────────────────────────────────────────────
# Спит N секунд. Без PG. Проверяет timeout и сосуществование долгих запросов.
resource "sless_service" "stress_slow" {
name = "stress-slow"
runtime = "python3.11"
entrypoint = "stress_slow.run"
memory_mb = 128
timeout_sec = 35
source_dir = "${path.module}/code/stress-slow"
depends_on = [sless_job.postgres_table_init_job]
}
# CPU-нагрузка: сумма квадратов N чисел. Без PG. Проверяет compute-bound задачи.
resource "sless_service" "stress_bigloop" {
name = "stress-bigloop"
runtime = "python3.11"
entrypoint = "stress_bigloop.run"
memory_mb = 256
timeout_sec = 60
source_dir = "${path.module}/code/stress-bigloop"
depends_on = [sless_job.postgres_table_init_job]
}
# ZeroDivisionError. Без PG. Проверяет перехват Python-исключений → HTTP 500.
resource "sless_service" "stress_divzero" {
name = "stress-divzero"
runtime = "python3.11"
entrypoint = "stress_divzero.run"
memory_mb = 128
timeout_sec = 10
source_dir = "${path.module}/code/stress-divzero"
depends_on = [sless_job.postgres_table_init_job]
}
# Параллельный INSERT в terraform_demo_table через psycopg2. Проверяет PG-write.
resource "sless_service" "stress_writer" {
name = "stress-writer"
runtime = "python3.11"
entrypoint = "stress_writer.run"
memory_mb = 128
timeout_sec = 60
source_dir = "${path.module}/code/stress-writer"
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
}
depends_on = [sless_job.postgres_table_init_job]
}
# Агрегированная статистика terraform_demo_table (COUNT/MIN/MAX). Для мониторинга.
resource "sless_service" "pg_stats" {
name = "pg-stats"
runtime = "python3.11"
entrypoint = "pg_stats.get_stats"
memory_mb = 128
timeout_sec = 15
source_dir = "${path.module}/code/pg-stats"
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
}
depends_on = [sless_job.postgres_table_init_job]
}
+176
View File
@@ -0,0 +1,176 @@
#!/bin/bash
# test_cache_matrix.sh — 2026-03-23 (v4)
# Комплексный тест кэша registry:
# Phase 1 — полный деплой всех 24 ресурсов (kaniko builds, т.к. нет образов)
# Phase 2 — destroy sless_* + re-apply (все образы из кэша)
# Phase 3 — одновременно: удаление 2, смена кода 2, смена параметров 2
# ВАЖНО: postgres.tf НЕ переименовывается и НЕ трогается никогда.
# Destroy sless-ресурсов делается путём переименования tf-файлов в .tf.bak,
# затем terraform apply (видит что ресурсов нет → удаляет их из state+кластера),
# затем файлы возвращаются обратно. Никаких -target.
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
LOG="$DIR/test_cache_matrix_$(date +%Y%m%d_%H%M%S).log"
TIMINGS="$LOG.timings"
PASS=0
FAIL=0
log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG"; }
sep() { log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"; }
timed_op() {
local label="$1"; shift
log "▶ START: $label"
local t0; t0=$(date +%s%3N)
"$@" 2>&1 | tee -a "$LOG"
local rc=${PIPESTATUS[0]}
local t1; t1=$(date +%s%3N)
local elapsed=$(( (t1 - t0) / 1000 ))
if [[ $rc -eq 0 ]]; then
log "✓ DONE: $label — ${elapsed}s"
PASS=$((PASS+1))
else
log "✗ FAIL: $label — ${elapsed}s (exit $rc)"
FAIL=$((FAIL+1))
fi
echo "$label: ${elapsed}s" >> "$TIMINGS"
return $rc
}
destroy_sless_only() {
# Переименовываем tf-файлы с sless-ресурсами в .tf.bak → terraform apply их удалит.
# Никаких -target — чтобы не затрагивать postgres и не получать state drift.
local label="$1"
local SLESS_FILES=("chaos_marathon.tf" "functions.tf" "stress.tf")
local has_state
has_state=$(terraform state list 2>/dev/null | grep -cE '^(sless_service|sless_job)' || true)
if [[ "$has_state" -eq 0 ]]; then
log " (nothing to destroy for $label — state empty)"
return 0
fi
log " Hiding sless tf-files → apply will destroy $has_state resources"
for f in "${SLESS_FILES[@]}"; do
[[ -f "$DIR/$f" ]] && mv "$DIR/$f" "$DIR/$f.bak"
done
timed_op "$label" terraform apply -auto-approve
for f in "${SLESS_FILES[@]}"; do
[[ -f "$DIR/$f.bak" ]] && mv "$DIR/$f.bak" "$DIR/$f"
done
log " sless tf-files restored"
}
cd "$DIR"
sep
log "PHASE 1: Полный начальный деплой"
sep
destroy_sless_only "phase1-pre-clean"
timed_op "phase1-apply-all" terraform apply -auto-approve
log "--- Образы в registry после Phase 1 ---"
kubectl exec -n sless deployment/sless-registry -- sh -c 'find /var/lib/registry -name "*.json" -path "*/tags/*" 2>/dev/null | sed "s|.*repository/||;s|/_manifests.*||" | sort | uniq -c | sort -rn' 2>/dev/null | head -30 | tee -a "$LOG" || log "(registry inspect failed)"
sep
log "PHASE 2: Destroy sless_* → Re-apply (ожидаем cache hits)"
sep
destroy_sless_only "phase2-destroy"
timed_op "phase2-apply-cached" terraform apply -auto-approve
sep
log "PHASE 3: Mixed ops (delete+code+params)"
sep
log "--- 3a: destroy stress_divzero, chaos_echo (comment out → apply → restore) ---"
python3 - <<'PYEOF'
import re, pathlib
def comment_out_resource(path, resource_type, resource_name):
text = pathlib.Path(path).read_text()
# Находим блок resource "type" "name" { ... } и оборачиваем в /* */
pattern = rf'(resource\s+"{re.escape(resource_type)}"\s+"{re.escape(resource_name)}"\s*\{{)'
match = re.search(pattern, text)
if not match:
print(f" WARNING: {resource_type}.{resource_name} not found in {path}")
return
# Найти закрывающую скобку блока
start = match.start()
depth = 0
i = match.start()
while i < len(text):
if text[i] == '{': depth += 1
elif text[i] == '}':
depth -= 1
if depth == 0:
end = i + 1
break
i += 1
block = text[start:end]
commented = "/* COMMENTED_OUT_FOR_TEST\n" + block + "\nCOMMENTED_OUT_FOR_TEST */"
pathlib.Path(path).write_text(text[:start] + commented + text[end:])
print(f" commented out: {resource_type}.{resource_name} in {path}")
comment_out_resource("stress.tf", "sless_service", "stress_divzero")
comment_out_resource("chaos_marathon.tf", "sless_service", "chaos_echo")
PYEOF
timed_op "phase3a-destroy-2" terraform apply -auto-approve
# Восстанавливаем закомментированные блоки
python3 - <<'PYEOF'
import pathlib, re
for fname in ("stress.tf", "chaos_marathon.tf"):
p = pathlib.Path(fname)
text = p.read_text()
text = re.sub(r'/\* COMMENTED_OUT_FOR_TEST\n', '', text)
text = re.sub(r'\nCOMMENTED_OUT_FOR_TEST \*/', '', text)
p.write_text(text)
print(f" restored: {fname}")
PYEOF
log " stress_divzero, chaos_echo removed from state and k8s"
log "--- 3b: code changes (new sha256 → kaniko) ---"
echo "" >> "$DIR/code/pg-counter/pg_counter.py"
echo "# cache-test-$(date +%s)" >> "$DIR/code/pg-counter/pg_counter.py"
echo "" >> "$DIR/code/stress-js-async/stress_js_async.js"
echo "// cache-test-$(date +%s)" >> "$DIR/code/stress-js-async/stress_js_async.js"
log " changed: pg_counter.py, stress_js_async.js"
log "--- 3c: param changes (same sha256 → no kaniko) ---"
python3 - <<'PYEOF'
import re, sys
with open("stress.tf") as f:
content = f.read()
orig = content
content = re.sub(
r'(resource "sless_service" "stress_slow" \{[^}]*?)memory_mb\s*=\s*\d+',
lambda m: m.group(1) + 'memory_mb = 192',
content, flags=re.DOTALL
)
content = re.sub(
r'(resource "sless_service" "pg_stats" \{[^}]*?)timeout_sec\s*=\s*\d+',
lambda m: m.group(1) + 'timeout_sec = 20',
content, flags=re.DOTALL
)
if content == orig:
print(" stress.tf: no changes (already patched?)", file=sys.stderr)
else:
with open("stress.tf", "w") as f:
f.write(content)
print(" stress.tf: stress_slow→memory_mb=192, pg_stats→timeout_sec=20")
PYEOF
log "--- 3d: apply всех mixed изменений ---"
log " Expected: stress_divzero+chaos_echo=cache_hit, pg_counter+stress_js_async=kaniko, stress_slow+pg_stats=k8s_only"
timed_op "phase3d-mixed-apply" terraform apply -auto-approve
sep
log "ИТОГ"
sep
log "Timings:"
cat "$TIMINGS" 2>/dev/null | tee -a "$LOG"
log "Pass: $PASS | Fail: $FAIL"
log "Лог: $LOG"
+93
View File
@@ -0,0 +1,93 @@
# Пример: Виртуальная машина (vApp + VM) в Nubes vDC
Создаёт:
- **vApp** — виртуальный каталог (контейнер для ВМ в VMware vDC)
- **ВМ** — Ubuntu 22.04, 2 CPU / 2 GB RAM / 20 GB disk
---
## Что нужно сделать перед запуском
### 1. Сгенерировать SSH-ключ
Публичный ключ прописывается в ВМ при создании — это единственный способ зайти по SSH.
Приватный ключ нужен хранить у себя.
```bash
ssh-keygen -t ed25519 -f ~/.ssh/sless-demo-vm -N "" -C "sless-demo-vm"
```
Публичный ключ (`~/.ssh/sless-demo-vm.pub`) — строка вида:
```
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... sless-demo-vm
```
### 2. Заполнить terraform.tfvars
Открыть файл `terraform.tfvars` и заменить значения:
```hcl
# Ваш API-токен из панели Nubes
api_token = "ВСТАВИТЬ_ТОКЕН"
# Публичный ключ из шага 1
vm_public_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA..."
```
> **Токен** — берётся в панели Nubes: профиль → API-токены.
> **Ключ** — содержимое файла `~/.ssh/sless-demo-vm.pub` (публичный, не приватный!).
---
## Запуск
```bash
cd examples/VM
terraform init
terraform apply
```
После `apply` в выводе будет:
```
Outputs:
vm_id = "..."
vm_state = {
"externalIp" = "1.2.3.4"
...
}
vapp_id = "..."
```
---
## Подключение по SSH
```bash
ssh -i ~/.ssh/sless-demo-vm ubuntu@<externalIp из outputs>
```
Логин — `ubuntu` (задан в `vm.tf`).
---
## Удаление
```bash
terraform destroy
```
Порядок автоматический: сначала suspend → потом delete. Без suspend удаление упадёт с ошибкой — это поведение Nubes, параметр `suspend_on_destroy = true` в ресурсах решает это.
---
## Что можно менять
| Параметр | Файл | Примечание |
|----------|------|-----------|
| `vm_cpu`, `vm_ram`, `vm_disk` | `vm.tf` | Можно менять и переприменять |
| `resource_name`, `vapp_name` | `vapp.tf` | **Не изменяется после создания** |
| `image_vm`, `user_login`, `user_public_key` | `vm.tf` | **Не изменяется после создания** |
| `vdc_uid`, `nsxt_uid` | `vapp.tf` | **Не изменяется после создания** |
+38
View File
@@ -0,0 +1,38 @@
// 2026-03-25 — main.tf для примера с vApp + ВМ (Виртуальный датацентр Nubes).
// Провайдер nubes. Sless-провайдер не нужен — пример чисто инфраструктурный.
terraform {
required_providers {
nubes = {
source = "terra.k8c.ru/nubes/nubes"
version = "5.0.31"
}
}
}
# ------------------------------------------------------------------
# Переменные
# ------------------------------------------------------------------
variable "vm_public_key" {
type = string
sensitive = true
description = "Публичный SSH-ключ для ВМ. Приватный ключ: ~/terra/sless/examples/VM/vm_key"
}
variable "api_token" {
type = string
sensitive = true
description = "Nubes API token"
}
# ------------------------------------------------------------------
# Провайдер
# ------------------------------------------------------------------
# API Dashboard (для Terraform-провайдеров): https://deck-api-test.ngcloud.ru/api/v1/index.cfm
# UI облака (только браузер): https://deck-test.ngcloud.ru/
provider "nubes" {
api_token = var.api_token
api_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm"
}
+28
View File
@@ -0,0 +1,28 @@
// 2026-03-25 — vapp.tf: виртуальный каталог ВМ (vApp) в Nubes vDC.
// nubes_vapp — контейнер для ВМ внутри Виртуального датацентра.
// Обязательные поля: vdc_uid, nsxt_uid, vapp_name, resource_name.
resource "nubes_vapp" "vapp" {
resource_name = "vm-sless-demo-vapp"
vapp_name = "vapp-sless-demo" # Уникальное в рамках организации. Не изменяется после создания.
vdc_uid = "e3c9e4f1-24da-4992-a003-f8a2a803a5f0" # UUID Услуги «Виртуальный датацентр (vDC)». Не изменяется после создания.
nsxt_uid = "0fe88e2a-31b6-4385-ad52-e27c6c0d38a6" # UUID Услуги «Сетевой шлюз периметра (Edge)». Не изменяется после создания.
adopt_existing_on_create = true
operation_timeout = "15m"
# ВАЖНО: delete без предварительного suspend завершается ошибкой
# "Невозможно выполнить операцию удаления услуги. Услуга не остановлена"
# suspend_on_destroy гарантирует правильный порядок: suspend → delete.
suspend_on_destroy = true
}
output "vapp_id" {
value = nubes_vapp.vapp.id
description = "ID созданного vApp (используется как vapp_uid при создании ВМ)"
}
output "vapp_state" {
value = nubes_vapp.vapp.state_out_flat
description = "Плоский state vApp — адреса, статусы сети и т.д."
}
+34
View File
@@ -0,0 +1,34 @@
// 2026-03-25 — vm.tf: виртуальная машина (nubes_vc_vm_v3) внутри vApp.
// Зависит от nubes_vapp.vapp — создаётся после vApp.
// image_vm, vapp_uid, user_public_key не изменяются после создания.
resource "nubes_vc_vm_v3" "vm" {
resource_name = "vm-sless-demo"
vapp_uid = nubes_vapp.vapp.id # ссылка на vApp. Не изменяется после создания.
image_vm = "Ubuntu 22.04 LTS" # Не изменяется после создания.
ip_space_name = "internet-ipv4-v1"
user_login = "ubuntu"
user_public_key = var.vm_public_key # задаётся в terraform.tfvars
vm_cpu = 2
vm_ram = 2 # GB
vm_disk = 20 # GB
adopt_existing_on_create = true
operation_timeout = "15m"
# delete без предварительного suspend завершается ошибкой (аналогично vApp).
suspend_on_destroy = true
}
output "vm_id" {
value = nubes_vc_vm_v3.vm.id
description = "ID созданной ВМ"
}
output "vm_state" {
value = nubes_vc_vm_v3.vm.state_out_flat
description = "Плоский state ВМ — IP-адреса, статус и т.д."
}
+73 -11
View File
@@ -1,5 +1,6 @@
// Изменено: 2026-03-18 (добавлены created_at, last_built_at в functionResponse и fnToResponse)
// Изменено: 2026-03-21 (fix: DeleteFunction возвращает 404 вместо 204 при отсутствующем объекте)
// Изменено: 2026-03-22 (fix: CreateFunction 409 при пересоздании функции — не учитывался DeletionTimestamp)
// functions.go — CRUD handlers для Function CRD.
// Принимает JSON, создаёт/обновляет/удаляет k8s ресурсы Function.
// Namespace берётся из URL: /v1/namespaces/{namespace}/functions/{name}
@@ -9,6 +10,7 @@ package handler
import (
"encoding/json"
"net/http"
"time"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -128,22 +130,59 @@ func (h *Handler) CreateFunction(w http.ResponseWriter, r *http.Request) {
S3Key: req.S3Key,
},
}
// Пытаемся создать Function CRD в k8s (запись в etcd через controller-runtime).
if err := h.K8s.Create(r.Context(), fn); err != nil {
// IsAlreadyExists = etcd вернул 409 CONFLICT.
// Возникает в двух основных сценариях:
//
// [A] terraform apply -replace (= delete + create за один apply):
// 1. terraform DELETE /functions/{name} → API вызывает h.K8s.Delete(fn)
// 2. k8s ставит DeletionTimestamp и ждёт снятия finalizer sless.kube5s.ru/finalizer
// function_controller.go убивает kaniko Job и снимает finalizer асинхронно
// 3. terraform сразу POST /functions/{name} → IsAlreadyExists ← БАГ до этого фикса
// → НОВЫЙ КОД: обнаруживает DeletionTimestamp → ждёт исчезновения → создаёт
//
// [B] Split-brain кеша controller-runtime:
// Объект удалён из etcd, но informer-кеш ещё не обновился.
// Create падает с IsAlreadyExists из кеша, Get возвращает NotFound → пересоздаём.
//
// [C] Функция в фазе Failed:
// Предыдущий build провалился. Новый apply пытается создать снова.
// Удаляем Failed объект и пересоздаём.
if errors.IsAlreadyExists(err) {
// IsAlreadyExists может прийти из кеша controller-runtime (split-brain):
// объект удалён из etcd, но кеш informer ещё южив. Делаем uncached Get:
// если реально NotFound — кеш устарел, пересоздаём.
// если существует и фаза Failed — тоже пересоздаём (build провалился, терраформ не добавил в state).
// если существует и фаза Ready/Building — возвращаем 409 (функция реально есть).
// Получаем актуальное состояние объекта из etcd (не из кеша informer).
existing := &slessv1alpha1.Function{}
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)
// --- Сценарий A: объект ожидает удаления ---
// DeletionTimestamp ≠ zero = k8s принял DELETE, finalizer ещё не снят.
// function_controller.go снимает finalizer после cleanup Job — обычно 1-5 сек.
if getErr == nil && !existing.DeletionTimestamp.IsZero() {
deleted := false
// Polling каждую секунду, максимум 30 раз (= 30 секунд).
// 30 сек — запас на медленный кластер; в норме 1-3 итерации.
for i := 0; i < 30; i++ {
time.Sleep(1 * time.Second)
checkErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing)
// NotFound = finalizer снят, объект исчез из etcd — можно создавать.
if errors.IsNotFound(checkErr) {
deleted = true
break
}
// Любая другая ошибка (timeout, сбой API) — продолжаем ждать.
}
// Сбрасываем ResourceVersion — при split-brain etcd считает объект новым
// Таймаут: объект не исчез за 30 секунд.
// Клиент (terraform) получит 409 и должен сделать retry позже.
if !deleted {
writeJSON(w, http.StatusConflict, errResp("function is being deleted, try again later"))
return
}
// Объект исчез — сбрасываем ResourceVersion и создаём как новый.
fn.ResourceVersion = ""
if createErr := h.K8s.Create(r.Context(), fn); createErr != nil {
writeJSON(w, http.StatusInternalServerError, errResp(createErr.Error()))
@@ -152,6 +191,29 @@ func (h *Handler) CreateFunction(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, fnToResponse(fn))
return
}
// --- Сценарии B и C: split-brain или Failed ---
// NotFound при Get = кеш врёт (B); Failed фаза = сломанный build (C).
shouldRecreate := errors.IsNotFound(getErr) ||
(getErr == nil && existing.Status.Phase == slessv1alpha1.FunctionPhaseFailed)
if shouldRecreate {
// Если объект реально есть (Failed) — сначала удаляем.
// Ошибку Delete игнорируем: Create покажет ошибку сам если что-то пошло не так.
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()))
return
}
writeJSON(w, http.StatusCreated, fnToResponse(fn))
return
}
// Объект живой (Ready/Building), DeletionTimestamp=zero.
// Легитимный конфликт — клиент пытается создать дубликат.
writeJSON(w, http.StatusConflict, errResp("function already exists"))
return
}
+21 -6
View File
@@ -1,5 +1,6 @@
// Изменено: 2026-03-20 (merge: FunctionJob теперь самодостаточен — убран FunctionRef, добавлены Runtime/Entrypoint/Env)
// Изменено: 2026-03-21 (fix: DeleteJob возвращает 404 вместо 204 при отсутствующем объекте)
// Изменено: 2026-03-22 (fix: UploadJobCode retry loop против cache lag controller-runtime)
// jobs.go — CRUD handlers для FunctionJob CRD.
// Создаёт/читает/удаляет k8s FunctionJob ресурсы.
// Namespace берётся из URL: /v1/namespaces/{namespace}/jobs/{name}
@@ -7,7 +8,9 @@
package handler
import (
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
@@ -185,14 +188,24 @@ func (h *Handler) UploadJobCode(w http.ResponseWriter, r *http.Request) {
ns := namespace(r)
name := pathVar(r, "name")
// Читаем FunctionJob для получения runtime
// Читаем FunctionJob для получения runtime.
// Retry до 5 раз с задержкой 200мс — защита от cache lag controller-runtime:
// сразу после POST /jobs (201) informer cache может ещё не синхронизировать новый CR.
fj := &slessv1alpha1.FunctionJob{}
if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, fj); err != nil {
if errors.IsNotFound(err) {
var getJobErr error
for i := 0; i < 5; i++ {
getJobErr = h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, fj)
if getJobErr == nil || !errors.IsNotFound(getJobErr) {
break
}
time.Sleep(200 * time.Millisecond)
}
if getJobErr != nil {
if errors.IsNotFound(getJobErr) {
writeJSON(w, http.StatusNotFound, errResp("job not found"))
return
}
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
writeJSON(w, http.StatusInternalServerError, errResp(getJobErr.Error()))
return
}
@@ -221,8 +234,10 @@ func (h *Handler) UploadJobCode(w http.ResponseWriter, r *http.Request) {
return
}
// Версия на основе timestamp — каждый upload → новый уникальный ключ в S3
version := time.Now().Format("20060102150405")
// Версия = sha256(загруженный zip). Одинаковый код → одинаковый s3Key → cache hit.
// Изменился код → новый хеш → новый build.
zipHash := sha256.Sum256(zipData)
version := fmt.Sprintf("%x", zipHash[:])[:16]
s3Key, err := h.S3.UploadContext(r.Context(), ns, name, version, buf, int64(buf.Len()))
if err != nil {
writeJSON(w, http.StatusInternalServerError, errResp("upload to S3: "+err.Error()))
+110 -13
View File
@@ -1,5 +1,7 @@
// Создано: 2026-03-20 (function-service-split)
// Изменено: 2026-03-21 (fix: DeleteService возвращает 404 вместо 204 при отсутствующем объекте)
// Изменено: 2026-03-22 (fix: CreateService 409 при пересоздании сервиса через terraform -replace)
// Изменено: 2026-03-23 (fix: UploadServiceCode retry при 404 из-за cache lag controller-runtime)
// services.go — CRUD handlers для Service CRD (sless_service).
// sless_service = long-running Deployment + URL. Каждый вызов проксируется к поду.
// Namespace берётся из URL: /v1/namespaces/{namespace}/services/{name}
@@ -7,7 +9,9 @@
package handler
import (
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
@@ -134,18 +138,69 @@ func (h *Handler) CreateService(w http.ResponseWriter, r *http.Request) {
S3Key: req.S3Key,
},
}
// Пытаемся создать Service CRD в k8s.
// h.K8s.Create обращается к etcd через controller-runtime client.
if err := h.K8s.Create(r.Context(), svc); err != nil {
// IsAlreadyExists = etcd вернул 409 CONFLICT.
// Это нормально при конкурентных запросах или при "replace" через terraform.
// Сценарии:
//
// [A] terraform apply -replace:
// 1. terraform DELETE /services/{name} → API вызывает h.K8s.Delete(svc)
// 2. k8s НЕ удаляет объект немедленно: ставит DeletionTimestamp и ждёт
// пока service_controller.go снимет finalizer sless.kube5s.ru/service-finalizer
// (контроллер сначала сносит Deployment/Service/Ingress)
// 3. terraform сразу POST /services/{name} → h.K8s.Create → IsAlreadyExists
// (объект ещё есть в etcd, просто помечен на удаление)
// → СТАРЫЙ КОД: видел phase=Ready, shouldRecreate=false → возвращал 409 ← БАГ
// → НОВЫЙ КОД: видит DeletionTimestamp → ждёт исчезновения → создаёт
//
// [B] Кеш controller-runtime (split-brain):
// Объект удалён из etcd, но informer-кеш ещё не обновился.
// h.K8s.Create падает с IsAlreadyExists из кеша, хотя в etcd объекта нет.
// → Get возвращает NotFound → пересоздаём.
//
// [C] Сервис в фазе Failed:
// Предыдущий build провалился, terraform не добавил в state.
// Новый apply пытается создать снова → Failed объект удаляем и пересоздаём.
if errors.IsAlreadyExists(err) {
// Повторяем логику function_handler: обрабатываем split-brain кеша.
// Если объект реально есть и не в фазе Failed — возвращаем 409.
// Получаем актуальное состояние объекта напрямую из etcd (не из кеша).
// Нужно чтобы точно определить сценарий A/B/C.
existing := &slessv1alpha1.Service{}
getErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing)
shouldRecreate := errors.IsNotFound(getErr) ||
(getErr == nil && existing.Status.Phase == slessv1alpha1.ServicePhaseFailed)
if shouldRecreate {
if getErr == nil {
_ = h.K8s.Delete(r.Context(), existing)
// --- Сценарий A: объект помечен на удаление ---
// DeletionTimestamp ≠ zero означает что k8s принял DELETE и ждёт finalizer.
// Finalizer снимает service_controller.go асинхронно (обычно 1-5 сек).
// Мы не можем создать объект пока старый существует — ждём его исчезновения.
if getErr == nil && !existing.DeletionTimestamp.IsZero() {
deleted := false
// Проверяем каждую секунду до 30 итераций (= 30 секунд максимум).
// Обычно finalizer снимается за 1-3 секунды, 30 — запас на перегруженный кластер.
for i := 0; i < 30; i++ {
time.Sleep(1 * time.Second)
checkErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing)
// IsNotFound = объект полностью исчез из etcd — можно создавать.
if errors.IsNotFound(checkErr) {
deleted = true
break
}
// Любая другая ошибка Get — продолжаем ждать (может быть временный сбой API).
}
// 30 секунд прошло, объект всё ещё не удалён.
// Вероятно контроллер завис или finalizer не снимается.
// Возвращаем 409 с понятным сообщением — клиент (terraform) должен retry.
if !deleted {
writeJSON(w, http.StatusConflict, errResp("service is being deleted, try again later"))
return
}
// Объект исчез. Сбрасываем ResourceVersion чтобы k8s воспринял как новый объект.
// ResourceVersion заполняется при первом Get — при Create должен быть пустым.
svc.ResourceVersion = ""
if createErr := h.K8s.Create(r.Context(), svc); createErr != nil {
writeJSON(w, http.StatusInternalServerError, errResp(createErr.Error()))
@@ -154,11 +209,39 @@ func (h *Handler) CreateService(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, svcToResponse(svc))
return
}
// --- Сценарии B и C: split-brain кеша или объект в фазе Failed ---
// shouldRecreate=true если:
// - getErr = NotFound (кеш врёт — объекта в etcd нет)
// - объект есть, но в фазе Failed (предыдущий build провалился)
shouldRecreate := errors.IsNotFound(getErr) ||
(getErr == nil && existing.Status.Phase == slessv1alpha1.ServicePhaseFailed)
if shouldRecreate {
// Если объект реально существует (Failed) — удаляем его перед пересозданием.
// Ошибку удаления игнорируем: даже если Delete упал,
// следующий Create либо пройдёт (объект исчез) либо снова упадёт с понятной ошибкой.
if getErr == nil {
_ = h.K8s.Delete(r.Context(), existing)
}
// Сбрасываем ResourceVersion — при split-brain etcd считает объект новым.
svc.ResourceVersion = ""
if createErr := h.K8s.Create(r.Context(), svc); createErr != nil {
writeJSON(w, http.StatusInternalServerError, errResp(createErr.Error()))
return
}
writeJSON(w, http.StatusCreated, svcToResponse(svc))
return
}
// Объект существует, DeletionTimestamp=zero, фаза не Failed.
// Это легитимный конфликт — сервис реально работает, клиент пытается создать дубликат.
writeJSON(w, http.StatusConflict, errResp("service already exists"))
return
}
// k8s возвращает StatusInvalid (422) при нарушении enum-валидации CRD (например, неизвестный runtime)
// — маппим это в 400, а не в 500
// k8s возвращает StatusInvalid (422) при нарушении enum-валидации CRD.
// Например: runtime="java8" которого нет в openapi enum сервисного CRD.
// Маппим в 400 (Bad Request) а не в 500 — это ошибка клиента, не сервера.
if errors.IsInvalid(err) {
writeJSON(w, http.StatusBadRequest, errResp("invalid service spec: "+err.Error()))
return
@@ -270,13 +353,24 @@ func (h *Handler) UploadServiceCode(w http.ResponseWriter, r *http.Request) {
ns := namespace(r)
name := pathVar(r, "name")
// Retry до 5 раз с задержкой 200мс — защита от cache lag controller-runtime.
// Проблема: после POST /services (201) кеш informer может не успеть обновиться,
// и Get возвращает IsNotFound в течение ~100-400мс после создания CR.
svc := &slessv1alpha1.Service{}
if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, svc); err != nil {
if errors.IsNotFound(err) {
var getErr error
for i := 0; i < 5; i++ {
getErr = h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, svc)
if getErr == nil || !errors.IsNotFound(getErr) {
break
}
time.Sleep(200 * time.Millisecond)
}
if getErr != nil {
if errors.IsNotFound(getErr) {
writeJSON(w, http.StatusNotFound, errResp("service not found"))
return
}
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
writeJSON(w, http.StatusInternalServerError, errResp(getErr.Error()))
return
}
@@ -303,7 +397,10 @@ func (h *Handler) UploadServiceCode(w http.ResponseWriter, r *http.Request) {
return
}
version := time.Now().Format("20060102150405")
// Версия = sha256(загруженный zip). Одинаковый код → одинаковый s3Key → cache hit.
// Изменился код → новый хеш → новый build.
zipHash := sha256.Sum256(zipData)
version := fmt.Sprintf("%x", zipHash[:])[:16]
s3Key, err := h.S3.UploadContext(r.Context(), ns, name, version, buf, int64(buf.Len()))
if err != nil {
writeJSON(w, http.StatusInternalServerError, errResp("upload to S3: "+err.Error()))
+6 -3
View File
@@ -10,9 +10,10 @@
package handler
import (
"crypto/sha256"
"fmt"
"io"
"net/http"
"time"
"k8s.io/apimachinery/pkg/api/errors"
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -65,8 +66,10 @@ func (h *Handler) UploadCode(w http.ResponseWriter, r *http.Request) {
return
}
// Версия на основе timestamp — каждый upload → новый уникальный ключ в S3
version := time.Now().Format("20060102150405")
// Версия = sha256(загруженный zip). Одинаковый код → одинаковый s3Key → cache hit.
// Изменился код → новый хеш → новый build.
zipHash := sha256.Sum256(zipData)
version := fmt.Sprintf("%x", zipHash[:])[:16]
s3Key, err := h.S3.UploadContext(r.Context(), ns, name, version, buf, int64(buf.Len()))
if err != nil {
writeJSON(w, http.StatusInternalServerError, errResp("upload to S3: "+err.Error()))
+164 -38
View File
@@ -1,16 +1,22 @@
// Изменено: 2026-03-11
// Изменено: 2026-03-22
// Builder — запускает kaniko Job в k8s для сборки Docker образа из кода функции.
// Почему kaniko, а не docker-in-docker (DinD):
// kaniko не требует privileged контейнер, что безопаснее в managed кластере.
// kaniko читает контекст сборки из S3 напрямую.
// Workflow: FunctionController вызывает Build → EnsureProject (Harbor) → Job запускается → образ пушится в registry.
// ImageExists: перед сборкой проверяем наличие образа по тегу в registry.
// Если образ с таким тегом (= hash кода) уже существует — сборка пропускается.
// Это даёт идемпотентность destroy+apply: если код не менялся, deploy мгновенный.
package builder
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
batchv1 "k8s.io/api/batch/v1"
@@ -28,47 +34,55 @@ type Projecter interface {
// Builder — управляет сборкой Docker образов через kaniko Jobs в k8s.
type Builder struct {
client client.Client
builderImage string // образ kaniko
registryHost string // куда пушим образ (DockerHub: "naeel"; Harbor: "host")
registryProject string // проект/org внутри registry (Harbor: project; DockerHub: пусто)
registrySecret string // имя k8s Secret с docker-кредами для kaniko
s3Endpoint string // откуда kaniko берёт код
s3AccessKey string
s3SecretKey string
s3Bucket string
namespace string // namespace где запускаем build Job'ы
harborClient Projecter // nil если Harbor не используется
client client.Client
builderImage string // образ kaniko
registryHost string // куда пушим образ (DockerHub: "naeel"; Harbor: "host")
registryProject string // проект/org внутри registry (Harbor: project; DockerHub: пусто)
registrySecret string // имя k8s Secret с docker-кредами для kaniko
registryInsecure bool // true = in-cluster HTTP registry, без TLS и авторизации
s3Endpoint string // откуда kaniko берёт код
s3AccessKey string
s3SecretKey string
s3Bucket string
namespace string // namespace где запускаем build Job'ы
harborClient Projecter // nil если Harbor не используется
}
// Config — параметры для создания Builder'а.
type Config struct {
BuilderImage string
RegistryHost string
RegistryProject string // пусто = DockerHub-режим (2 уровня); задан = project-режим (3 уровня)
RegistrySecret string // имя k8s Secret с .dockerconfigjson для пуша образов
S3Endpoint string
S3AccessKey string
S3SecretKey string
S3Bucket string
Namespace string
HarborClient Projecter // nil — Harbor не используется, EnsureProject пропускается
BuilderImage string
RegistryHost string
RegistryProject string // пусто = DockerHub-режим (2 уровня); задан = project-режим (3 уровня)
RegistrySecret string // имя k8s Secret с .dockerconfigjson для пуша образов
RegistryInsecure bool // true = HTTP registry (in-cluster), kaniko получает --insecure
S3Endpoint string
S3AccessKey string
S3SecretKey string
S3Bucket string
Namespace string
HarborClient Projecter // nil — Harbor не используется, EnsureProject пропускается
}
// New создаёт новый Builder.
func New(c client.Client, cfg Config) *Builder {
// In-cluster HTTP registry не требует docker credentials — монтировать Secret не нужно.
registrySecret := cfg.RegistrySecret
if cfg.RegistryInsecure {
registrySecret = ""
}
return &Builder{
client: c,
builderImage: cfg.BuilderImage,
registryHost: cfg.RegistryHost,
registryProject: cfg.RegistryProject,
registrySecret: cfg.RegistrySecret,
s3Endpoint: cfg.S3Endpoint,
s3AccessKey: cfg.S3AccessKey,
s3SecretKey: cfg.S3SecretKey,
s3Bucket: cfg.S3Bucket,
namespace: cfg.Namespace,
harborClient: cfg.HarborClient,
client: c,
builderImage: cfg.BuilderImage,
registryHost: cfg.RegistryHost,
registryProject: cfg.RegistryProject,
registrySecret: registrySecret,
registryInsecure: cfg.RegistryInsecure,
s3Endpoint: cfg.S3Endpoint,
s3AccessKey: cfg.S3AccessKey,
s3SecretKey: cfg.S3SecretKey,
s3Bucket: cfg.S3Bucket,
namespace: cfg.Namespace,
harborClient: cfg.HarborClient,
}
}
@@ -103,6 +117,108 @@ func (b *Builder) ImageRef(namespace, funcName, s3Key string) string {
return fmt.Sprintf("%s/%s-%s:%s", b.registryHost, nsPrefix, funcName, tag)
}
// ImageExists проверяет наличие образа в Docker Registry v2 по тегу (без pull).
// Возвращает (true, nil) если образ есть, (false, nil) если нет,
// (false, err) если registry недоступен — контроллер должен requeue, не запускать сборку.
//
// Два режима:
// - insecure (in-cluster registry:2): HTTP, без TLS, без авторизации
// - secure (DockerHub): HTTPS, анонимный bearer-token для публичных репо
func (b *Builder) ImageExists(ctx context.Context, imageRef string) (bool, error) {
colonIdx := strings.LastIndex(imageRef, ":")
if colonIdx < 0 {
return false, nil
}
repoFull := imageRef[:colonIdx]
tag := imageRef[colonIdx+1:]
if b.registryInsecure {
// In-cluster HTTP registry — без авторизации и TLS.
// repoFull вида "host:port/path" → берём host до первого /
slashIdx := strings.Index(repoFull, "/")
if slashIdx < 0 {
return false, nil
}
registryHost := repoFull[:slashIdx]
repo := repoFull[slashIdx+1:]
httpClient := &http.Client{Timeout: 5 * time.Second}
manifestURL := fmt.Sprintf("http://%s/v2/%s/manifests/%s", registryHost, repo, tag)
req, err := http.NewRequestWithContext(ctx, http.MethodHead, manifestURL, nil)
if err != nil {
return false, err
}
req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json")
resp, err := httpClient.Do(req)
if err != nil {
// Registry недоступен — не притворяемся что образа нет, requeue
return false, fmt.Errorf("registry unavailable at %s: %w", registryHost, err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusNotFound:
return false, nil
default:
return false, fmt.Errorf("unexpected registry response: %d", resp.StatusCode)
}
}
// Secure registry (DockerHub или HTTPS).
// DockerHub: "naeel/sless-ff-pg" → registry = index.docker.io
// Приватный: "harbor.host/proj/func" → registry = harbor.host
registryHost := "index.docker.io"
repo := repoFull
if parts := strings.SplitN(repoFull, "/", 3); len(parts) == 3 {
registryHost = parts[0]
repo = parts[1] + "/" + parts[2]
}
tokenURL := fmt.Sprintf("https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull", repo)
if registryHost != "index.docker.io" {
tokenURL = ""
}
httpClient := &http.Client{Timeout: 5 * time.Second}
var bearerToken string
if tokenURL != "" {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil)
if err != nil {
return false, nil
}
resp, err := httpClient.Do(req)
if err != nil || resp.StatusCode != http.StatusOK {
return false, nil
}
defer resp.Body.Close()
var tkResp struct {
Token string `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&tkResp); err != nil {
return false, nil
}
bearerToken = tkResp.Token
}
manifestURL := fmt.Sprintf("https://%s/v2/%s/manifests/%s", registryHost, repo, tag)
req, err := http.NewRequestWithContext(ctx, http.MethodHead, manifestURL, nil)
if err != nil {
return false, nil
}
req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json")
if bearerToken != "" {
req.Header.Set("Authorization", "Bearer "+bearerToken)
}
resp, err := httpClient.Do(req)
if err != nil {
return false, nil
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK, nil
}
// Build запускает kaniko Job для сборки образа функции.
// Возвращает имя Job'а чтобы контроллер мог следить за его статусом.
func (b *Builder) Build(ctx context.Context, namespace, funcName, s3Key string) (string, error) {
@@ -142,11 +258,7 @@ func (b *Builder) Build(ctx context.Context, namespace, funcName, s3Key string)
{
Name: "kaniko",
Image: b.builderImage,
Args: []string{
"--context=" + s3ContextURL,
"--destination=" + imageRef,
// --no-cache не поддерживается этой версией kaniko; кэш отключён по умолчанию
},
Args: b.kanikoArgs(s3ContextURL, imageRef),
Env: []corev1.EnvVar{
{Name: "AWS_ACCESS_KEY_ID", Value: b.s3AccessKey},
{Name: "AWS_SECRET_ACCESS_KEY", Value: b.s3SecretKey},
@@ -210,6 +322,20 @@ func (b *Builder) Cleanup(ctx context.Context, jobName string) error {
return b.client.Delete(ctx, job, &client.DeleteOptions{PropagationPolicy: &propagation})
}
// kanikoArgs строит аргументы для kaniko контейнера.
// Добавляет --insecure если registry работает по HTTP (in-cluster).
func (b *Builder) kanikoArgs(s3ContextURL, imageRef string) []string {
args := []string{
"--context=" + s3ContextURL,
"--destination=" + imageRef,
}
if b.registryInsecure {
// --insecure: push в HTTP registry без TLS (in-cluster registry:2)
args = append(args, "--insecure")
}
return args
}
// dockerConfigVolumes возвращает Volume с docker-кредами если RegistrySecret задан.
// Ключ .dockerconfigjson монтируется как config.json — стандартное имя для kaniko.
func (b *Builder) dockerConfigVolumes() []corev1.Volume {
+21 -8
View File
@@ -93,23 +93,30 @@ func generateDockerfile(runtime string, hasRequirements bool, hasPackageJSON boo
// Go: multi-stage build с go.work (v0.1.3+).
// base-образ содержит: /app/server/ (module sless/fn/server, package main).
// kaniko копирует пользовательский код в /app/handler/.
// Генерируем go.mod для handler если его нет (module sless/fn/handler).
// Генерируем go.work: use ./server + use ./handler + replace sless/fn/handler => ./handler.
// replace позволяет server.go импортировать sless/fn/handler независимо от имени модуля пользователя.
// GOFLAGS=-mod=mod: позволяет go build подтягивать зависимости из handler/go.mod через GOPROXY.
// Модуль handler всегда переименовывается в sless/fn/handler — это позволяет server.go
// импортировать его по фиксированному пути без replace-директивы.
// Почему нет replace: Go 1.23 запрещает replace для модуля, который одновременно в workspace
// (use ./handler). Ошибка: "workspace module sless/fn/handler is replaced at all versions".
// Решение: rename module → use workspace → require без replace.
goModStep := ""
if !hasGoMod {
// Пользователь не предоставил go.mod — генерируем минимальный
// Пользователь не предоставил go.mod — генерируем минимальный с правильным именем.
goModStep = "RUN printf 'module sless/fn/handler\\n\\ngo 1.23\\n' > /app/handler/go.mod\n"
} else {
// Пользователь предоставил go.mod с произвольным именем модуля — переименовываем.
goModStep = "RUN sed -i 's/^module .*/module sless\\/fn\\/handler/' /app/handler/go.mod\n"
}
content := fmt.Sprintf(
"FROM %s AS builder\n"+
"WORKDIR /app\n"+
"COPY . /app/handler/\n"+
goModStep+
"RUN printf 'go 1.23\\n\\nuse ./server\\nuse ./handler\\n\\nreplace sless/fn/handler => ./handler\\n' > /app/go.work\n"+ // server/go.mod не содержит require sless/fn/handler — Go требует явного require даже при replace в go.work.
// Патчим server/go.mod в момент kaniko-сборки (не меняет базовый образ).
"RUN printf '\\nrequire sless/fn/handler v0.0.0\\n' >> /app/server/go.mod\n"+"RUN CGO_ENABLED=0 go build -o /server ./server\n"+
// go.work: workspace из двух модулей без replace-директив.
"RUN printf 'go 1.23\\n\\nuse ./server\\nuse ./handler\\n' > /app/go.work\n"+
// Патчим server/go.mod в момент kaniko-сборки — добавляем require на handler.
// Не меняет базовый образ (выполняется на этапе сборки пользовательского образа).
"RUN printf '\\nrequire sless/fn/handler v0.0.0\\n' >> /app/server/go.mod\n"+
"RUN CGO_ENABLED=0 go build -o /server ./server\n"+
"FROM alpine:3.20\n"+
"COPY --from=builder /server /server\n"+
"EXPOSE 8080\n"+
@@ -125,11 +132,17 @@ func generateDockerfile(runtime string, hasRequirements bool, hasPackageJSON boo
if hasRequirements {
content += "RUN pip install --no-cache-dir -r /app/function/requirements.txt\n"
}
// Проверяем синтаксис всех .py файлов на этапе сборки образа.
// Если в коде синтаксическая ошибка — kaniko завершится с ошибкой и фаза = Failed.
// Без этого шага образ собирается успешно, а под падает в CrashLoopBackOff при запуске.
content += "RUN python -m py_compile $(find /app/function -name '*.py')\n"
case "nodejs20":
if hasPackageJSON {
// cd нужен т.к. npm install читает package.json из текущей директории
content += "RUN cd /app/function && npm install --omit=dev\n"
}
// Проверяем синтаксис всех .js файлов через node --check.
content += "RUN find /app/function -name '*.js' | xargs -r node --check\n"
}
return []byte(content), nil
}
+10
View File
@@ -40,6 +40,11 @@ type Config struct {
// Создаётся через hack/create-registry-secret.sh
RegistrySecret string
// RegistryInsecure — использовать HTTP (без TLS) для registry.
// Нужно для in-cluster registry:2 где нет сертификата.
// Включается через REGISTRY_INSECURE=true.
RegistryInsecure bool
// BuilderImage — образ для сборки функций (kaniko или buildah)
BuilderImage string
@@ -128,6 +133,11 @@ func Load() (*Config, error) {
cfg.RegistrySecret = "sless-registry-auth"
}
// REGISTRY_INSECURE=true — использовать HTTP вместо HTTPS для registry (in-cluster registry:2)
if os.Getenv("REGISTRY_INSECURE") == "true" {
cfg.RegistryInsecure = true
}
// Опциональные параметры с дефолтами
if v := os.Getenv("BUILDER_IMAGE"); v != "" {
cfg.BuilderImage = v
+11 -10
View File
@@ -131,16 +131,17 @@ func main() {
}
bldr := builder.New(mgr.GetClient(), builder.Config{
BuilderImage: cfg.BuilderImage,
RegistryHost: cfg.RegistryHost,
RegistryProject: cfg.RegistryProject,
RegistrySecret: cfg.RegistrySecret,
S3Endpoint: cfg.S3Endpoint,
S3AccessKey: cfg.S3AccessKey,
S3SecretKey: cfg.S3SecretKey,
S3Bucket: cfg.S3Bucket,
Namespace: "sless",
HarborClient: harborProjecter,
BuilderImage: cfg.BuilderImage,
RegistryHost: cfg.RegistryHost,
RegistryProject: cfg.RegistryProject,
RegistrySecret: cfg.RegistrySecret,
RegistryInsecure: cfg.RegistryInsecure,
S3Endpoint: cfg.S3Endpoint,
S3AccessKey: cfg.S3AccessKey,
S3SecretKey: cfg.S3SecretKey,
S3Bucket: cfg.S3Bucket,
Namespace: "sless",
HarborClient: harborProjecter,
})
if err = (&controllers.FunctionReconciler{
+14 -10
View File
@@ -1,18 +1,22 @@
# Изменено: 2026-03-19 — v0.1.1: добавлены go.sum + go mod download (pgx/v5 v5.7.2).
# Изменено: 2026-03-23 — v0.1.4: fix go.work (убран replace, добавлен sed rename модуля).
# Base builder image для Go 1.23 serverless функций.
# Это BUILDER-образ: golang:1.23-alpine + server.go + pre-cached зависимости.
# Это BUILDER-образ: golang:1.23-alpine + server/ + pre-cached зависимости.
# go mod download кеширует pgx/v5 в /root/go/pkg/mod — kaniko не скачивает их при каждой сборке.
#
# kaniko генерирует Dockerfile для каждой функции:
# FROM naeel/sless-runtime-go1.23:v0.1.1 AS builder
# FROM naeel/sless-runtime-go1.23:v0.1.3 AS builder
# WORKDIR /app
# COPY . /app/handler/
# RUN CGO_ENABLED=0 go build -o /server .
# RUN [ -f /app/handler/go.mod ] && sed -i 's/^module .*/module sless\/fn\/handler/' || printf '...' > /app/handler/go.mod
# RUN printf 'go 1.23\n\nuse ./server\nuse ./handler\n' > /app/go.work # БЕЗ replace!
# RUN printf '\nrequire sless/fn/handler v0.0.0\n' >> /app/server/go.mod
# RUN CGO_ENABLED=0 go build -o /server ./server
# FROM alpine:3.20
# COPY --from=builder /server /server → финальный образ
# COPY --from=builder /server /server
#
# Почему не multi-stage здесь: base образ должен иметь Go компилятор и module cache.
# Бинарник собирается kaniko, а не здесь.
# Почему replace убран: Go 1.23 запрещает replace для модуля, который одновременно в workspace.
# Ошибка: "workspace module sless/fn/handler is replaced at all versions in the go.work file".
# Решение: handler всегда переименовывается в sless/fn/handler через sed — replace не нужен.
FROM golang:1.23-alpine
@@ -20,10 +24,10 @@ WORKDIR /app
# Копируем mod файлы первыми — отдельный кешируемый слой.
# При изменении go.mod/go.sum слой инвалидируется и go mod download запускается заново.
COPY go.mod go.sum /app/
RUN go mod download
COPY server/go.mod server/go.sum /app/server/
RUN cd /app/server && go mod download
# server.go — HTTP-wrapper + job-runner (main package, импортирует sless/fn/handler).
COPY server.go /app/server.go
COPY server/server.go /app/server/server.go
EXPOSE 8080
+15
View File
@@ -0,0 +1,15 @@
// Изменено: 2026-03-22 — переименован в sless/fn/server для поддержки go.work + user go.mod
module sless/fn/server
go 1.23
require github.com/jackc/pgx/v5 v5.7.2
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/text v0.21.0 // indirect
)
+28
View File
@@ -0,0 +1,28 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+97
View File
@@ -0,0 +1,97 @@
// Создано: 2026-03-11
// Изменено: 2026-03-21 (v0.1.2) — panic recovery: при panic в Handle() возвращаем HTTP 500 вместо EOF.
// HTTP-обёртка для serverless функций на Go 1.23.
// Компилируется kaniko ВМЕСТЕ с пользовательским кодом (package handler).
//
// Интерфейс пользователя — файл handler.go:
//
// package handler
//
// func Handle(event map[string]interface{}) interface{} {
// return map[string]interface{}{"hello": event["name"]}
// }
//
// SLESS_MODE=job: разово вызвать Handle(event) → вывести JSON → выйти (для FunctionJob).
// По умолчанию: HTTP-сервер, каждый запрос → Handle(event) → JSON ответ.
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"sless/fn/handler"
)
func main() {
mode := os.Getenv("SLESS_MODE")
// job-runner: разово вызвать Handle(event), вывести результат в JSON и выйти.
if mode == "job" {
eventJSON := os.Getenv("SLESS_EVENT")
if eventJSON == "" {
eventJSON = "{}"
}
var event map[string]interface{}
if err := json.Unmarshal([]byte(eventJSON), &event); err != nil {
event = map[string]interface{}{}
}
result := handler.Handle(event)
out, _ := json.Marshal(result)
fmt.Println(string(out))
return
}
// HTTP-сервер
port := "8080"
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"status":"ok"}`)
})
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Перехватываем panic в пользовательском коде — возвращаем 500 вместо EOF.
// Без recover() Go закрывает соединение при панике → ingress видит EOF → 502.
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic recovered: %v", rec)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `{"error":"panic: %v"}`, rec)
}
}()
event := map[string]interface{}{}
if r.Method == http.MethodPost || r.Method == http.MethodPut {
body, err := io.ReadAll(r.Body)
if err == nil && len(body) > 0 {
_ = json.Unmarshal(body, &event)
}
}
event["_path"] = r.URL.Path
event["_method"] = r.Method
if q := r.URL.Query(); len(q) > 0 {
qmap := map[string]interface{}{}
for k, v := range q {
if len(v) == 1 {
qmap[k] = v[0]
} else {
qmap[k] = v
}
}
event["_query"] = qmap
}
result := handler.Handle(event)
out, err := json.Marshal(result)
if err != nil {
http.Error(w, `{"error":"marshal failed"}`, 500)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(out)
})
log.Printf("sless runtime (go1.23) listening on :%s", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
+17 -1
View File
@@ -1,4 +1,5 @@
// Изменено: 2026-03-09
// Изменено: 2026-04-10 (v0.1.3) — sendResult: если handler вернул строку начинающуюся с '<' → text/html.
// HTTP-обёртка для serverless функций на Node.js 20.
// Загружает модуль из SLESS_ENTRYPOINT или handler.js по умолчанию.
// Формат SLESS_ENTRYPOINT: "module-name.functionName" (например: handler-http.handle)
@@ -65,7 +66,7 @@ const server = http.createServer(async (req, res) => {
try {
const result = await userHandle(event);
sendJSON(res, 200, result);
sendResult(res, 200, result);
} catch (err) {
console.error('Handler error:', err);
sendJSON(res, 500, { error: err.message });
@@ -73,6 +74,21 @@ const server = http.createServer(async (req, res) => {
});
});
function sendResult(res, status, data) {
// Если handler вернул строку начинающуюся с '<' — HTML страница.
// Если строка, но не HTML — text/plain. Иначе — JSON.
if (typeof data === 'string') {
const ctype = data.trimStart().startsWith('<')
? 'text/html; charset=utf-8'
: 'text/plain; charset=utf-8';
const buf = Buffer.from(data, 'utf-8');
res.writeHead(status, { 'Content-Type': ctype, 'Content-Length': buf.length });
res.end(buf);
return;
}
sendJSON(res, status, data);
}
function sendJSON(res, status, data) {
const body = JSON.stringify(data);
res.writeHead(status, {
+1 -1
View File
@@ -27,7 +27,7 @@ spec:
spec:
containers:
- name: funcs
image: naeel/sless-funcs-service:v0.1.3
image: naeel/sless-funcs-service:v0.1.11
ports:
- containerPort: 8090
env:
+644 -24
View File
@@ -188,15 +188,25 @@
color: #8b949e;
}
.fn-url-row {
padding: 0 14px 9px 14px;
display: flex;
align-items: center;
gap: 6px;
}
.fn-url-row:empty {
display: none;
}
.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;
white-space: nowrap;
max-width: 100%;
}
.fn-url:hover {
@@ -312,6 +322,197 @@
color: #484f58;
font-size: 0.9rem;
}
/* Кнопки действий на карточке функции */
.action-btn {
flex-shrink: 0;
padding: 3px 10px;
border-radius: 5px;
border: 1px solid #30363d;
cursor: pointer;
font-size: 0.73rem;
font-weight: 500;
background: #21262d;
color: #8b949e;
transition: background 0.1s, color 0.1s;
}
.action-btn:hover { background: #30363d; color: #c9d1d9; }
.action-btn.del:hover { background: #3d1a1a; border-color: #6b1a1a; color: #f85149; }
/* Кодовый редактор во встроенном режиме */
.edit-area {
width: 100%;
min-height: 220px;
background: #0d1117;
color: #e6edf3;
border: none;
border-top: 1px solid #30363d;
padding: 14px 16px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.78rem;
line-height: 1.55;
resize: vertical;
outline: none;
display: block;
box-sizing: border-box;
}
.edit-actions {
display: flex;
gap: 8px;
padding: 8px 14px;
background: #161b22;
border-top: 1px solid #30363d;
}
.edit-save-btn {
padding: 5px 16px;
background: #238636;
border: 1px solid #2ea043;
color: #fff;
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
}
.edit-save-btn:hover { background: #2ea043; }
.edit-save-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.edit-cancel-btn {
padding: 5px 16px;
background: #21262d;
border: 1px solid #30363d;
color: #c9d1d9;
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
}
.edit-status {
font-size: 0.77rem;
padding: 5px 8px;
align-self: center;
color: #8b949e;
}
.edit-status.ok { color: #3fb950; }
.edit-status.err { color: #f85149; }
/* ---------- Модалка создания функции ---------- */
.modal-backdrop {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,0.65);
z-index: 100;
align-items: center;
justify-content: center;
}
.modal-backdrop.open { display: flex; }
.modal {
background: #161b22;
border: 1px solid #30363d;
border-radius: 10px;
width: min(660px, 95vw);
max-height: 90vh;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.modal h2 {
font-size: 1rem;
font-weight: 600;
color: #e6edf3;
}
.modal label {
font-size: 0.82rem;
color: #8b949e;
display: block;
margin-bottom: 4px;
}
.modal input, .modal select {
width: 100%;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #e6edf3;
padding: 7px 10px;
font-size: 0.85rem;
box-sizing: border-box;
outline: none;
}
.modal input:focus, .modal select:focus { border-color: #58a6ff; }
.modal-row { display: flex; gap: 12px; }
.modal-row > div { flex: 1; }
.modal-code {
width: 100%;
min-height: 200px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #e6edf3;
padding: 10px 12px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.78rem;
line-height: 1.55;
resize: vertical;
outline: none;
box-sizing: border-box;
}
.modal-code:focus { border-color: #58a6ff; }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.modal-create-btn {
padding: 7px 20px;
background: #238636;
border: 1px solid #2ea043;
color: #fff;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
}
.modal-create-btn:hover { background: #2ea043; }
.modal-create-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.modal-cancel-btn {
padding: 7px 20px;
background: #21262d;
border: 1px solid #30363d;
color: #c9d1d9;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
}
.modal-err {
color: #f85149;
font-size: 0.8rem;
min-height: 18px;
}
.create-fn-btn {
margin-left: auto;
background: #238636;
border: 1px solid #2ea043;
color: #fff;
padding: 6px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 0.82rem;
transition: background 0.1s;
}
.create-fn-btn:hover { background: #2ea043; }
/* Таймер Building */
.fn-timer {
font-size: 0.72rem;
color: #d29922;
font-family: monospace;
margin-left: 2px;
flex-shrink: 0;
}
.badge-Building {
animation: pulse-bld 1.4s ease-in-out infinite;
}
@keyframes pulse-bld {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
</head>
@@ -321,12 +522,56 @@
<span class="logo-slash">/</span>
<span class="ns-badge" id="ns-label">…</span>
<button class="refresh-btn" onclick="location.reload()">↻ обновить</button>
<button class="create-fn-btn" id="open-create-modal">+ Создать функцию</button>
</header>
<main>
<p class="summary" id="summary"></p>
<div id="fn-list"></div>
</main>
<!-- Модалка создания новой функции -->
<div class="modal-backdrop" id="create-modal">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<h2 id="modal-title">Создать функцию</h2>
<div>
<label>Runtime</label>
<select id="m-runtime">
<option value="python3.11">Python 3.11</option>
<option value="nodejs20">Node.js 20</option>
</select>
</div>
<div class="modal-row">
<div>
<label>Имя функции (k8s name)</label>
<input id="m-name" type="text" placeholder="my-hello-fn" pattern="[a-z0-9][a-z0-9\-]{0,61}[a-z0-9]">
</div>
<div>
<label>Entrypoint (module.func)</label>
<input id="m-entrypoint" type="text" value="handler.handler">
</div>
</div>
<div>
<label id="m-filename-label">Файл: <code id="m-filename-display">handler.py</code></label>
<textarea class="modal-code" id="m-code" spellcheck="false"></textarea>
</div>
<div>
<label><span id="m-deps-name">requirements.txt</span> <span style="color:#484f58;font-size:0.75rem">(зависимости, опционально)</span></label>
<textarea class="modal-code" id="m-deps" spellcheck="false" style="min-height:80px"></textarea>
</div>
<div class="modal-err" id="m-err"></div>
<div class="modal-actions">
<button class="modal-cancel-btn" id="modal-cancel">Отмена</button>
<button class="modal-create-btn" id="modal-submit">Создать</button>
</div>
</div>
</div>
<script id="fndata" type="application/json">__PAGE_DATA__</script>
<script>
(function () {
@@ -355,6 +600,8 @@
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');
@@ -363,40 +610,46 @@
$txt(hdr, 'span', 'fn-name', fn.name);
$txt(hdr, 'span', 'badge badge-runtime', fn.runtime);
$txt(hdr, 'span', 'badge badge-' + fn.phase, fn.phase || 'Pending');
var phaseBadge = $txt(hdr, 'span', 'badge badge-' + fn.phase, fn.phase || 'Pending');
if (fn.kind) {
var kindLabel = fn.kind === 'service' ? 'always-on' : 'job';
$txt(hdr, 'span', 'badge badge-kind-' + fn.kind, kindLabel);
}
// sless_service имеет прямой URL из статуса деплоя
if (fn.url) {
// Для Building/Pending — таймер секунд + автополинг до Ready/Failed
if (fn.phase === 'Building' || fn.phase === 'Pending') {
var timerEl = $txt(hdr, 'span', 'fn-timer', '0с');
startBuildPoll(phaseBadge, timerEl, fn, ns, null, function(url) {
if (!fn.url) { fn.url = url; setCardUrl(url); }
});
}
if (fn.message) $txt(hdr, 'span', 'fn-msg', fn.message);
hdr.appendChild($el('span', 'spacer'));
// urlRow — отдельная строка под заголовком для URL
var urlRow = $el('div', 'fn-url-row');
var cardUrlAnchor = null;
function setCardUrl(url) {
if (cardUrlAnchor) return; // уже есть
var a = document.createElement('a');
a.className = 'fn-url';
a.href = fn.url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.textContent = 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);
urlRow.appendChild(a);
cardUrlAnchor = a;
}
if (fn.url) setCardUrl(fn.url);
for (var t = 0; t < triggers.length; t++) {
var tr = triggers[t];
if (tr.type === 'http') {
// Для job-style функций URL берём из триггера; для сервисов уже показан fn.url
if (!fn.url) {
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);
}
if (url) setCardUrl(url);
}
hdr.appendChild(makeTriggerBtn(tr, ns));
} else if (tr.type === 'cron') {
@@ -405,8 +658,44 @@
}
}
if (fn.message) $txt(hdr, 'span', 'fn-msg', fn.message);
hdr.appendChild($el('span', 'spacer'));
// Кнопка редактирования кода
var editBtn = $el('button', 'action-btn');
editBtn.textContent = '✎';
editBtn.title = 'Редактировать код';
editBtn.addEventListener('click', function (e) {
e.stopPropagation();
body.innerHTML = ''; // сбрасываем содержимое перед переходом в edit
loaded = false; // следующий expand снова зачитает исходники
body.style.display = 'block';
icon.style.transform = 'rotate(90deg)';
openEditMode(body, ns, fn.name, fn.kind, fn.runtime);
});
hdr.appendChild(editBtn);
// Кнопка удаления
var delBtn = $el('button', 'action-btn del');
delBtn.textContent = '✕';
delBtn.title = 'Удалить функцию';
delBtn.addEventListener('click', function (e) {
e.stopPropagation();
if (!confirm('Удалить функцию «' + fn.name + '»?\n\nЭто действие необратимо.')) return;
delBtn.disabled = true;
fetch('/funcs/' + ns + '/delete-function/' + fn.name + '?kind=' + (fn.kind || 'function'), {
method: 'DELETE'
}).then(function (r) {
if (r.ok) {
card.remove();
} else {
return r.text().then(function (t) { alert('Ошибка удаления: ' + t); });
}
}).catch(function (err) {
alert('Сетевая ошибка: ' + err);
}).finally(function () {
delBtn.disabled = false;
});
});
hdr.appendChild(delBtn);
var icon = $txt(hdr, 'span', 'expand-icon', '▶');
var loaded = false;
@@ -425,10 +714,226 @@
});
card.appendChild(hdr);
card.appendChild(urlRow);
card.appendChild(body);
return card;
}
// openEditMode — открывает тело карточки в режиме редактирования.
// Загружает текущий код и файл зависимостей (requirements.txt / package.json).
function openEditMode(body, ns, fnName, fnKind, fnRuntime) {
body.innerHTML = '';
var msg = $el('div', 'src-msg');
msg.textContent = 'Загрузка кода…';
body.appendChild(msg);
var kindParam = fnKind === 'service' ? '?kind=service' : '';
fetch('/funcs/' + ns + '/source/' + fnName + kindParam)
.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 editable = files.filter(function (f) {
return !f.binary && (f.name.endsWith('.py') || f.name.endsWith('.js'));
});
if (editable.length === 0) {
editable = [{ name: defaultFilename(fnRuntime), content: helloWorldTemplate(fnRuntime) }];
}
var mainFile = editable[0];
// Ищем файл зависимостей (requirements.txt / package.json)
var tpl = TEMPLATES[fnRuntime] || {};
var depsFilename = tpl.depsFilename || '';
var depsContent = tpl.deps || '';
if (depsFilename) {
for (var i = 0; i < files.length; i++) {
if (files[i].name === depsFilename && !files[i].binary) {
depsContent = files[i].content;
break;
}
}
}
renderEditArea(body, ns, fnName, fnKind, fnRuntime,
mainFile.name, mainFile.content, depsFilename, depsContent);
});
})
.catch(function (err) {
body.innerHTML = '';
var e = $el('div', 'src-msg');
e.textContent = 'Ошибка: ' + err;
body.appendChild(e);
});
}
// renderEditArea — rename input + code textarea + deps textarea + Save/Cancel.
function renderEditArea(body, ns, fnName, fnKind, fnRuntime, filename, initialCode, depsFilename, depsContent) {
body.innerHTML = '';
// Строка переименования
var renameRow = $el('div', 'edit-actions');
renameRow.style.borderBottom = '1px solid #30363d';
var nameLabel = $el('span', 'edit-status');
nameLabel.textContent = 'Имя:';
var nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.value = fnName;
nameInput.style.cssText = 'flex:1;max-width:240px;background:#0d1117;border:1px solid #30363d;border-radius:5px;color:#e6edf3;padding:4px 8px;font-size:0.8rem;outline:none;';
var renameBtn = $el('button', 'edit-cancel-btn');
renameBtn.textContent = 'Переименовать';
var renameStatus = $el('span', 'edit-status');
renameRow.appendChild(nameLabel);
renameRow.appendChild(nameInput);
renameRow.appendChild(renameBtn);
renameRow.appendChild(renameStatus);
body.appendChild(renameRow);
// Код функции
var codeHdr = $el('div', 'file-header');
codeHdr.textContent = filename;
body.appendChild(codeHdr);
var ta = document.createElement('textarea');
ta.className = 'edit-area';
ta.spellcheck = false;
ta.value = initialCode;
body.appendChild(ta);
// Файл зависимостей (если есть)
var depsTA = null;
if (depsFilename) {
var depsHdr = $el('div', 'file-header');
depsHdr.textContent = depsFilename;
body.appendChild(depsHdr);
depsTA = document.createElement('textarea');
depsTA.className = 'edit-area';
depsTA.style.minHeight = '80px';
depsTA.spellcheck = false;
depsTA.value = depsContent;
body.appendChild(depsTA);
}
// Кнопки Save / Cancel
var actions = $el('div', 'edit-actions');
var saveBtn = $el('button', 'edit-save-btn');
saveBtn.textContent = 'Сохранить';
var cancelBtn = $el('button', 'edit-cancel-btn');
cancelBtn.textContent = 'Закрыть';
var status = $el('span', 'edit-status');
actions.appendChild(saveBtn);
actions.appendChild(cancelBtn);
actions.appendChild(status);
body.appendChild(actions);
saveBtn.addEventListener('click', function () {
saveBtn.disabled = true;
status.textContent = 'Сохраняю…';
status.className = 'edit-status';
fetch('/funcs/' + ns + '/save-function/' + fnName, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: ta.value, filename: filename, kind: fnKind || 'function',
deps: depsTA ? depsTA.value : '',
deps_filename: depsFilename || ''
})
}).then(function (r) {
saveBtn.disabled = false;
if (r.ok) {
// Ищем phaseBadge текущей карточки через DOM
var cardEl = body.closest ? body.closest('.fn-card') : null;
var pb = cardEl ? cardEl.querySelector('.badge-Ready, .badge-Building, .badge-Pending, .badge-Failed, .badge-Error') : null;
if (pb) { pb.className = 'badge badge-Building'; pb.textContent = 'Building'; }
status.textContent = '⏳ Сборка… 0с';
status.className = 'edit-status';
// Запускаем поллинг: обновляет статус в строке редактора и badge карточки
startBuildPoll(pb, null, { name: fnName, kind: fnKind || 'function' }, ns, status, null);
} else {
return r.text().then(function (t) {
status.textContent = '✗ ' + t;
status.className = 'edit-status err';
});
}
}).catch(function (err) {
saveBtn.disabled = false;
status.textContent = '✗ ' + err;
status.className = 'edit-status err';
});
});
cancelBtn.addEventListener('click', function () { body.style.display = 'none'; });
renameBtn.addEventListener('click', function () {
var newName = nameInput.value.trim();
if (newName === fnName) { renameStatus.textContent = 'Имя не изменилось'; return; }
if (!/^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$/.test(newName)) {
renameStatus.textContent = '✗ Только строчные буквы, цифры, дефисы';
renameStatus.className = 'edit-status err';
return;
}
renameBtn.disabled = true;
renameStatus.textContent = 'Переименовываю…';
renameStatus.className = 'edit-status';
fetch('/funcs/' + ns + '/rename-function/' + fnName, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_name: newName, kind: fnKind || 'function' })
}).then(function (r) {
if (r.ok) {
renameStatus.textContent = '✓ Переименовано → ' + newName;
renameStatus.className = 'edit-status ok';
setTimeout(function () { location.reload(); }, 800);
} else {
return r.text().then(function (t) {
renameStatus.textContent = '✗ ' + t;
renameStatus.className = 'edit-status err';
});
}
}).catch(function (err) {
renameStatus.textContent = '✗ ' + err;
renameStatus.className = 'edit-status err';
}).finally(function () { renameBtn.disabled = false; });
});
}
// startBuildPoll — опрашивает /fn-status каждые 5с пока phase=Building/Pending.
// Показывает таймер-строку в statusEl (если передан), обновляет phaseBadge,
// добавляет URL через onUrl-коллбэк когда сервис появляется.
function startBuildPoll(phaseBadge, timerEl, fn, ns, statusEl, onUrl) {
var buildStart = Date.now();
var timerItv = setInterval(function () {
var sec = Math.floor((Date.now() - buildStart) / 1000);
if (timerEl && timerEl.parentNode) timerEl.textContent = sec + 'с';
if (statusEl) statusEl.textContent = '⏳ Сборка… ' + sec + 'с';
}, 1000);
var pollItv = setInterval(function () {
fetch('/funcs/' + ns + '/fn-status/' + fn.name + '?kind=' + (fn.kind || 'function'))
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.phase || d.phase === 'Building' || d.phase === 'Pending') return;
clearInterval(timerItv);
clearInterval(pollItv);
if (phaseBadge) {
phaseBadge.className = 'badge badge-' + d.phase;
phaseBadge.textContent = d.phase;
}
if (timerEl && timerEl.parentNode) timerEl.parentNode.removeChild(timerEl);
if (statusEl) {
statusEl.textContent = d.phase === 'Ready'
? '✓ Сборка завершена — функция готова'
: '✗ Сборка завершилась со статусом: ' + d.phase;
statusEl.className = 'edit-status ' + (d.phase === 'Ready' ? 'ok' : 'err');
}
if (d.url && onUrl) onUrl(d.url);
})
.catch(function () {});
}, 5000);
}
function makeTriggerBtn(tr, ns) {
var btn = $el('button', 'trigger-btn ' + (tr.enabled ? 'state-on' : 'state-off'));
btn.textContent = tr.enabled ? '■ стоп' : '▶ запуск';
@@ -459,6 +964,7 @@
}
function loadSource(body, ns, fnName, fnKind) {
body.innerHTML = ''; // всегда очищаем перед загрузкой
var msg = $el('div', 'src-msg');
msg.textContent = 'Загрузка кода…';
body.appendChild(msg);
@@ -506,6 +1012,120 @@
});
}
// ---------- Модалка создания функции ----------
var TEMPLATES = {
'python3.11': {
filename: 'handler.py',
code: '# Hello World — Python 3.11\n# Entrypoint: handler.handler\n\ndef handler(event):\n name = event.get("name", "World")\n return {"result": f"Hello, {name}!"}\n',
depsFilename: 'requirements.txt',
deps: '# Python packages\n# e.g.: requests==2.31.0\n'
},
'nodejs20': {
filename: 'handler.js',
code: '// Hello World — Node.js 20\n// Entrypoint: handler.handler\n\nexports.handler = async (event) => {\n const name = event.name || \'World\';\n return { result: `Hello, ${name}!` };\n};\n',
depsFilename: 'package.json',
deps: '{\n "dependencies": {}\n}\n'
}
};
function defaultFilename(runtime) {
return TEMPLATES[runtime] ? TEMPLATES[runtime].filename : 'handler.py';
}
function helloWorldTemplate(runtime) {
return TEMPLATES[runtime] ? TEMPLATES[runtime].code : '# код функции\n';
}
var modal = document.getElementById('create-modal');
var mRuntime = document.getElementById('m-runtime');
var mCode = document.getElementById('m-code');
var mFilenameDisplay = document.getElementById('m-filename-display');
var mErr = document.getElementById('m-err');
// Заполняем шаблон при открытии / смене runtime
function applyTemplate() {
var tpl = TEMPLATES[mRuntime.value];
if (tpl) {
mCode.value = tpl.code;
mFilenameDisplay.textContent = tpl.filename;
document.getElementById('m-deps-name').textContent = tpl.depsFilename;
document.getElementById('m-deps').value = tpl.deps || '';
}
}
applyTemplate();
mRuntime.addEventListener('change', applyTemplate);
document.getElementById('open-create-modal').addEventListener('click', function () {
applyTemplate();
mErr.textContent = '';
document.getElementById('m-name').value = '';
document.getElementById('m-entrypoint').value = 'handler.handler';
modal.classList.add('open');
document.getElementById('m-name').focus();
});
document.getElementById('modal-cancel').addEventListener('click', function () {
modal.classList.remove('open');
});
modal.addEventListener('click', function (e) {
if (e.target === modal) modal.classList.remove('open');
});
document.getElementById('modal-submit').addEventListener('click', function () {
var name = document.getElementById('m-name').value.trim();
var runtime = mRuntime.value;
var entrypoint = document.getElementById('m-entrypoint').value.trim();
var code = mCode.value;
var filename = TEMPLATES[runtime] ? TEMPLATES[runtime].filename : 'handler.py';
mErr.textContent = '';
if (!name) { mErr.textContent = 'Укажите имя функции'; return; }
if (!/^[a-z0-9][a-z0-9\-]{0,61}[a-z0-9]$/.test(name) && !/^[a-z0-9]$/.test(name)) {
mErr.textContent = 'Имя должно быть в формате k8s: строчные буквы, цифры, дефисы';
return;
}
if (!entrypoint) { mErr.textContent = 'Укажите entrypoint (например handler.handler)'; return; }
if (!code.trim()) { mErr.textContent = 'Напишите код функции'; return; }
var btn = document.getElementById('modal-submit');
btn.disabled = true;
mErr.textContent = 'Создаю…';
fetch('/funcs/' + ns + '/create-function', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name,
runtime: runtime,
kind: 'service',
code: code,
filename: filename,
entrypoint: entrypoint,
memory_mb: 128,
timeout_sec: 30,
deps: document.getElementById('m-deps').value,
deps_filename: TEMPLATES[runtime] ? TEMPLATES[runtime].depsFilename : ''
})
}).then(function (r) {
if (r.ok) {
modal.classList.remove('open');
mErr.textContent = '';
// Перезагружаем страницу чтобы увидеть новую функцию
setTimeout(function () { location.reload(); }, 400);
} else {
return r.text().then(function (t) {
mErr.textContent = '✗ ' + t;
});
}
}).catch(function (err) {
mErr.textContent = '✗ Сетевая ошибка: ' + err;
}).finally(function () {
btn.disabled = false;
});
});
// ---------- Утилиты ----------
function detectLang(name) {
var ext = name.split('.').pop().toLowerCase();
var m = {
+398 -7
View File
@@ -1,15 +1,20 @@
// Изменено: 2026-03-21 (добавлен вывод sless_service вместе с функциями — пользователь видит оба типа как «функции»)
// Изменено: 2026-03-23 (deps поддержка, Building poll, rename, kind=service по умолч.)
// 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
// 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 /funcs/<namespace>/fn-status/<fn> — прокси: текущий phase+url функции (для UI polling)
// POST /funcs/<namespace>/create-function — создать функцию через UI (kind=service по умолч.)
// POST /funcs/<namespace>/save-function/<fn> — обновить код через UI (zip с code+deps)
// POST /funcs/<namespace>/rename-function/<fn> — переименовать: delete old + create new с тем же кодом
// DELETE /funcs/<namespace>/delete-function/<fn> — удалить функцию через UI
// GET /health — liveness/readiness probe
//
// Env vars:
// SLESS_OPERATOR_URL — URL оператора внутри кластера (default: http://sless-operator.sless.svc.cluster.local:9090)
@@ -21,7 +26,9 @@
package main
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
_ "embed"
"encoding/base64"
@@ -29,6 +36,7 @@ import (
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"os"
"regexp"
@@ -180,9 +188,51 @@ func handleFuncsNS(operatorURL, externalURL, serviceToken string, exclude map[st
}
proxyTriggerPatch(w, r, operatorURL, serviceToken, ns, parts[2])
return
case "fn-status":
// GET /funcs/{ns}/fn-status/{fn} — текущий phase+url для UI polling
if r.Method != http.MethodGet {
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
return
}
proxyFnStatus(w, r, operatorURL, serviceToken, ns, parts[2])
return
case "save-function":
// POST /funcs/{ns}/save-function/{fnName} — обновить код функции через UI
if r.Method != http.MethodPost {
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
return
}
proxySaveFunction(w, r, operatorURL, serviceToken, ns, parts[2])
return
case "rename-function":
// POST /funcs/{ns}/rename-function/{fn} — переименовать: delete+create с тем же кодом
if r.Method != http.MethodPost {
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
return
}
proxyRenameFunction(w, r, operatorURL, serviceToken, ns, parts[2])
return
case "delete-function":
// DELETE /funcs/{ns}/delete-function/{fnName} — удалить функцию через UI
if r.Method != http.MethodDelete {
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
return
}
proxyDeleteFunction(w, r, operatorURL, serviceToken, ns, parts[2])
return
}
}
if len(parts) == 2 && parts[1] == "create-function" {
// POST /funcs/{ns}/create-function — создать новую функцию через UI
if r.Method != http.MethodPost {
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
return
}
proxyCreateFunction(w, r, operatorURL, serviceToken, ns)
return
}
if len(parts) > 1 {
http.NotFound(w, r)
return
@@ -549,3 +599,344 @@ func env(key, fallback string) string {
}
return fallback
}
// proxyCreateFunction обрабатывает POST /funcs/{ns}/create-function.
// Создаёт функцию как sless_service (always-on, с URL) если kind не указан.
// Принимает code (основной файл) + deps (requirements.txt / package.json).
func proxyCreateFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns string) {
var req struct {
Name string `json:"name"`
Runtime string `json:"runtime"`
Kind string `json:"kind"` // "service" | "function"; default "service"
Code string `json:"code"`
Filename string `json:"filename"`
Deps string `json:"deps"` // содержимое requirements.txt / package.json
DepsFile string `json:"deps_filename"` // "requirements.txt" или "package.json"
Entrypoint string `json:"entrypoint"`
MemoryMB int `json:"memory_mb"`
TimeoutSec int `json:"timeout_sec"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
return
}
if req.Name == "" || req.Runtime == "" || req.Code == "" || req.Filename == "" || req.Entrypoint == "" {
http.Error(w, `{"error":"name, runtime, code, filename, entrypoint are required"}`, http.StatusBadRequest)
return
}
if !isValidK8sName(req.Name) {
http.Error(w, `{"error":"invalid function name"}`, http.StatusBadRequest)
return
}
if req.Kind == "" {
req.Kind = "service" // always-on по умолчанию — сразу получает URL
}
if req.MemoryMB <= 0 {
req.MemoryMB = 128
}
if req.TimeoutSec <= 0 {
req.TimeoutSec = 30
}
resourceType := "functions"
if req.Kind == "service" {
resourceType = "services"
}
// Шаг 1: создаём CRD через оператор (/functions или /services)
createBody, _ := json.Marshal(map[string]any{
"name": req.Name,
"runtime": req.Runtime,
"entrypoint": req.Entrypoint,
"memory_mb": req.MemoryMB,
"timeout_sec": req.TimeoutSec,
})
createURL := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType
createResp, err := operatorRequest(r.Context(), http.MethodPost, createURL, serviceToken, "application/json", bytes.NewReader(createBody))
if err != nil || (createResp.StatusCode != http.StatusCreated && createResp.StatusCode != http.StatusOK) {
code := http.StatusBadGateway
msg := "create CRD failed"
if createResp != nil {
b, _ := io.ReadAll(createResp.Body)
createResp.Body.Close()
msg = string(b)
code = createResp.StatusCode
}
http.Error(w, msg, code)
return
}
createResp.Body.Close()
// Шаг 2: упаковываем code + deps в zip и загружаем
zipFiles := map[string]string{req.Filename: req.Code}
if req.Deps != "" && req.DepsFile != "" {
zipFiles[req.DepsFile] = req.Deps
}
zipData, err := buildZipFiles(zipFiles)
if err != nil {
http.Error(w, `{"error":"zip build failed"}`, http.StatusInternalServerError)
return
}
uploadURL := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType + "/" + req.Name + "/upload"
if err := uploadZipToOperator(r.Context(), uploadURL, serviceToken, req.Filename, zipData); err != nil {
http.Error(w, "upload code: "+err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
fmt.Fprintf(w, `{"status":"ok","name":%q,"kind":%q}`, req.Name, req.Kind)
}
// proxySaveFunction обрабатывает POST /funcs/{ns}/save-function/{fnName}.
// Принимает code + deps (опционально), упаковывает в zip и загружает через оператор.
// kind=service → загружает в /services/{name}/upload, иначе → /functions/{name}/upload.
func proxySaveFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, fnName string) {
if !isValidK8sName(fnName) {
http.Error(w, `{"error":"invalid function name"}`, http.StatusBadRequest)
return
}
var req struct {
Code string `json:"code"`
Filename string `json:"filename"`
Kind string `json:"kind"` // "function" | "service"
Deps string `json:"deps"` // содержимое requirements.txt / package.json
DepsFile string `json:"deps_filename"` // имя файла зависимостей
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Code == "" || req.Filename == "" {
http.Error(w, `{"error":"code and filename are required"}`, http.StatusBadRequest)
return
}
zipFiles := map[string]string{req.Filename: req.Code}
if req.Deps != "" && req.DepsFile != "" {
zipFiles[req.DepsFile] = req.Deps
}
zipData, err := buildZipFiles(zipFiles)
if err != nil {
http.Error(w, `{"error":"zip build failed"}`, http.StatusInternalServerError)
return
}
resourceType := "functions"
if req.Kind == "service" {
resourceType = "services"
}
uploadURL := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType + "/" + fnName + "/upload"
if err := uploadZipToOperator(r.Context(), uploadURL, serviceToken, req.Filename, zipData); err != nil {
http.Error(w, "upload code: "+err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"status":"ok","name":%q}`, fnName)
}
// proxyDeleteFunction обрабатывает DELETE /funcs/{ns}/delete-function/{fnName}.
// Проксирует DELETE к оператору. kind=service → /services/{name}, иначе → /functions/{name}.
func proxyDeleteFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, fnName string) {
if !isValidK8sName(fnName) {
http.Error(w, `{"error":"invalid function name"}`, http.StatusBadRequest)
return
}
resourceType := "functions"
if r.URL.Query().Get("kind") == "service" {
resourceType = "services"
}
target := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType + "/" + fnName
resp, err := operatorRequest(r.Context(), http.MethodDelete, target, serviceToken, "", nil)
if err != nil {
http.Error(w, "operator error: "+err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
http.Error(w, string(b), resp.StatusCode)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"status":"ok","name":%q}`, fnName)
}
// buildZipFiles упаковывает набор текстовых файлов {filename→content} в zip-архив.
// Используется при создании/редактировании функций через UI.
// Поддерживает code + deps (requirements.txt / package.json) в одном архиве.
func buildZipFiles(files map[string]string) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for name, content := range files {
f, err := zw.Create(name)
if err != nil {
return nil, err
}
if _, err := io.WriteString(f, content); err != nil {
return nil, err
}
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// proxyFnStatus отдаёт текущий phase+url функции/сервиса — используется для UI polling.
// Клиент опрашивает каждые 5с пока phase=Building, потом останавливается.
func proxyFnStatus(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, fnName string) {
if !isValidK8sName(fnName) {
http.Error(w, `{"error":"invalid name"}`, http.StatusBadRequest)
return
}
resourceType := "functions"
if r.URL.Query().Get("kind") == "service" {
resourceType = "services"
}
resp, err := operatorRequest(r.Context(), http.MethodGet,
operatorURL+"/v1/namespaces/"+ns+"/"+resourceType+"/"+fnName, serviceToken, "", nil)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
w.Write(b) //nolint:errcheck
}
// proxyRenameFunction реализует переименование функции: delete old + create new + upload old code.
// Это атомарная операция с точки зрения UI, но не транзакционная на уровне k8s.
// При сбое на шагах 3-5 старая функция может остаться (UI покажет ошибку).
func proxyRenameFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, oldName string) {
if !isValidK8sName(oldName) {
http.Error(w, `{"error":"invalid old name"}`, http.StatusBadRequest)
return
}
var req struct {
NewName string `json:"new_name"`
Kind string `json:"kind"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.NewName == "" {
http.Error(w, `{"error":"new_name required"}`, http.StatusBadRequest)
return
}
if !isValidK8sName(req.NewName) {
http.Error(w, `{"error":"invalid new_name"}`, http.StatusBadRequest)
return
}
resourceType := "functions"
if req.Kind == "service" {
resourceType = "services"
}
ctx := r.Context()
baseURL := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType
// Шаг 1: получаем конфиг старой функции (runtime, entrypoint, memory, timeout)
cfgResp, err := operatorRequest(ctx, http.MethodGet, baseURL+"/"+oldName, serviceToken, "", nil)
if err != nil || cfgResp.StatusCode != http.StatusOK {
http.Error(w, "get old config failed", http.StatusBadGateway)
return
}
var cfg struct {
Runtime string `json:"runtime"`
Entrypoint string `json:"entrypoint"`
MemoryMB int `json:"memory_mb"`
TimeoutSec int `json:"timeout_sec"`
Env map[string]string `json:"env"`
}
json.NewDecoder(cfgResp.Body).Decode(&cfg) //nolint:errcheck
cfgResp.Body.Close()
// Шаг 2: получаем исходный код старой функции
srcResp, _ := operatorRequest(ctx, http.MethodGet, baseURL+"/"+oldName+"/source", serviceToken, "", nil)
zipFiles := map[string]string{}
if srcResp != nil && srcResp.StatusCode == http.StatusOK {
var files []struct {
Name string `json:"name"`
Content string `json:"content"`
Binary bool `json:"binary"`
}
if json.NewDecoder(srcResp.Body).Decode(&files) == nil {
for _, f := range files {
if !f.Binary {
zipFiles[f.Name] = f.Content
}
}
}
srcResp.Body.Close()
}
// Шаг 3: создаём новую функцию с новым именем
createBody, _ := json.Marshal(map[string]any{
"name": req.NewName, "runtime": cfg.Runtime, "entrypoint": cfg.Entrypoint,
"memory_mb": cfg.MemoryMB, "timeout_sec": cfg.TimeoutSec, "env": cfg.Env,
})
createResp, err := operatorRequest(ctx, http.MethodPost, baseURL, serviceToken, "application/json", bytes.NewReader(createBody))
if err != nil || (createResp.StatusCode != http.StatusCreated && createResp.StatusCode != http.StatusOK) {
msg := "create new failed"
if createResp != nil {
b, _ := io.ReadAll(createResp.Body)
createResp.Body.Close()
msg = string(b)
}
http.Error(w, msg, http.StatusBadGateway)
return
}
createResp.Body.Close()
// Шаг 4: загружаем старый код в новую функцию (если есть)
if len(zipFiles) > 0 {
if zipData, err := buildZipFiles(zipFiles); err == nil {
_ = uploadZipToOperator(ctx, baseURL+"/"+req.NewName+"/upload", serviceToken, req.NewName, zipData)
}
}
// Шаг 5: удаляем старую функцию
delResp, _ := operatorRequest(ctx, http.MethodDelete, baseURL+"/"+oldName, serviceToken, "", nil)
if delResp != nil {
delResp.Body.Close()
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"status":"ok","old":%q,"new":%q,"kind":%q}`, oldName, req.NewName, req.Kind)
}
// uploadZipToOperator отправляет zip-данные как multipart/form-data field "code" на URL оператора.
func uploadZipToOperator(ctx context.Context, url, token, filename string, zipData []byte) error {
var body bytes.Buffer
mw := multipart.NewWriter(&body)
fw, err := mw.CreateFormFile("code", filename+".zip")
if err != nil {
return err
}
if _, err := fw.Write(zipData); err != nil {
return err
}
mw.Close()
resp, err := operatorRequest(ctx, http.MethodPost, url, token, mw.FormDataContentType(), &body)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("status %d: %s", resp.StatusCode, b)
}
return nil
}
// operatorRequest выполняет HTTP-запрос к оператору с авторизацией через serviceToken.
func operatorRequest(ctx context.Context, method, url, token, contentType string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, err
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
return http.DefaultClient.Do(req)
}
@@ -253,15 +253,23 @@ func (r *JobResource) Create(ctx context.Context, req resource.CreateRequest, re
}
}
// Загружаем код если задан source_dir — контроллер начнёт kaniko сборку после upload
// Загружаем код если задан source_dir — контроллер начнёт kaniko сборку после upload.
// deleteOnFail — откат: удаляем CR если upload провалился,
// иначе при следующем apply будет 409 (CR есть, state пустой).
if !plan.SourceDir.IsNull() && plan.SourceDir.ValueString() != "" {
zipData, hash, err := zipDir(plan.SourceDir.ValueString())
if err != nil {
resp.Diagnostics.AddError("zip source_dir", err.Error())
if delErr := r.client.DeleteJob(ctx, ns, plan.Name.ValueString()); delErr != nil {
resp.Diagnostics.AddWarning("rollback delete job", delErr.Error())
}
return
}
if err := r.client.UploadJobCode(ctx, ns, plan.Name.ValueString(), "function.zip", bytes.NewReader(zipData)); err != nil {
resp.Diagnostics.AddError("upload job code", err.Error())
if delErr := r.client.DeleteJob(ctx, ns, plan.Name.ValueString()); delErr != nil {
resp.Diagnostics.AddWarning("rollback delete job", delErr.Error())
}
return
}
plan.CodeHash = types.StringValue(hash)
@@ -192,22 +192,31 @@ func (r *ServiceResource) Create(ctx context.Context, req resource.CreateRequest
return
}
// deleteOnFail — откат: удаляем CR если upload провалился,
// иначе при следующем apply будет 409 (CR есть, state пустой).
deleteOnFail := func(addErr string, err error) {
resp.Diagnostics.AddError(addErr, err.Error())
if delErr := r.client.DeleteService(ctx, ns, svc.Name); delErr != nil {
resp.Diagnostics.AddWarning("rollback delete service", delErr.Error())
}
}
var codeUploaded bool
if !plan.SourceDir.IsNull() && plan.SourceDir.ValueString() != "" {
zipData, hash, err := zipDir(plan.SourceDir.ValueString())
if err != nil {
resp.Diagnostics.AddError("zip source_dir", err.Error())
deleteOnFail("zip source_dir", err)
return
}
if err := r.client.UploadServiceCodeReader(ctx, ns, svc.Name, "code.zip", bytes.NewReader(zipData)); err != nil {
resp.Diagnostics.AddError("upload service code", err.Error())
deleteOnFail("upload service code", err)
return
}
plan.CodeHash = types.StringValue(hash)
codeUploaded = true
} else if !plan.CodePath.IsNull() && plan.CodePath.ValueString() != "" {
if err := r.client.UploadServiceCode(ctx, ns, svc.Name, plan.CodePath.ValueString()); err != nil {
resp.Diagnostics.AddError("upload service code", err.Error())
deleteOnFail("upload service code", err)
return
}
codeUploaded = true