Author SHA1 Message Date
Repinoid 3456510f51 chore: update ignore rules 2026-10-27 14:09:07 +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
31 changed files with 3 additions and 3112 deletions
-3
View File
@@ -69,12 +69,9 @@ event-dispatcher
# build artifacts
/sless
examples/POSTGRES/stress_log*.txt
examples/VM/vm_key
examples/VM/vm_key.pub
/deployments/k8s/operator-demo.yaml
/doc/decisions/log.md
/doc/log.md
/s3cfg_registry
/sqs-operator/quick_test.sh
/sqs-operator/sqs_load_test.sh
/.s3cfg_registry
-500
View File
@@ -1,500 +0,0 @@
# План-инструкция: sless_job функции для установки ПО в ВМ
> 2026-03-29 — Инструкция для AI-агента (GPT/Claude/Codex).
> Цель: демонстрация заказчику связки Terraform + Serverless.
> Один `terraform apply` — поднимается vApp/VM + автоматически устанавливается ПО.
---
## Контекст: что уже есть
```
examples/VM/
├── main.tf # provайдер nubes, переменные (api_token, vm_public_key)
├── vapp.tf # nubes_vapp.vapp — контейнер ВМ
├── vm.tf # nubes_vc_vm_v3.vm — Ubuntu 22.04, 2CPU/2GB/20GB
├── terraform.tfvars # токены (gitignored)
├── vm_key / vm_key.pub # SSH-ключ для ВМ
└── .terraform/ # init уже выполнен
```
**ВМ поднята и работает.** IP: `nubes_vc_vm_v3.vm.state_out_flat["externalConnect"]`.
SSH: `ssh -i vm_key ubuntu@<IP>`.
Платформа sless поднята: оператор v0.1.62, API `https://sless.kube5s.ru`.
---
## Что нужно сделать
Добавить в `examples/VM/` sless-провайдер и набор `sless_job` ресурсов, которые по SSH
устанавливают ПО на ВМ. Пользователь шаблона включает нужные флагами.
### Целевой результат для заказчика
```bash
cd examples/VM
# пользователь выставляет флаги:
# install_docker = true
# install_postgres = true
terraform apply
# → vApp + VM создаются (или уже есть)
# → джобы подключаются по SSH и ставят Docker, PostgreSQL и т.д.
# → outputs показывают статус каждого шага
```
---
## Архитектура: СТРОГО sless_job
- Каждая установка = отдельный `sless_job` (one-shot, execution task).
- НЕ создавать `sless_service` (у функций-установщиков нет постоянного URL).
- НЕ создавать новый Terraform resource — только `sless_job`.
- Передача параметров: `env_vars` — для подключения (IP, ключ), `event_json` — для бизнес-логики.
---
## Файловая структура (целевая)
```
examples/VM/
├── main.tf # + добавить provider "sless"
├── vapp.tf # без изменений
├── vm.tf # без изменений
├── variables.tf # NEW — все переменные (включая флаги install_*)
├── sless.tf # NEW — provider sless + sless_job ресурсы
├── outputs.tf # NEW — outputs статусов джобов
├── terraform.tfvars # + добавить sless_token, флаги
│
├── functions/ # NEW — код функций-установщиков
│ ├── install-packages/
│ │ ├── handler.py
│ │ └── requirements.txt # paramiko
│ ├── install-docker/
│ │ ├── handler.py
│ │ └── requirements.txt
│ └── install-postgres/
│ ├── handler.py
│ └── requirements.txt
│
├── vm_key / vm_key.pub
└── .terraform/
```
---
## Контракт Python handler для sless_job
```python
# handler.py — загружается в контейнер как /app/function/handler.py
# Рантайм: python3.11
# Вызывается один раз, результат = JSON → записывается в job.message
import os
def install(event):
"""
event — dict из event_json Terraform-ресурса.
os.environ — содержит env_vars из Terraform-ресурса.
Возврат:
dict/list → JSON (phase=Succeeded, message=json)
raise Exception → phase=Failed, message=traceback
"""
vm_ip = os.environ["VM_IP"]
ssh_user = os.environ["SSH_USER"]
ssh_key = os.environ["SSH_KEY"] # содержимое приватного ключа (PEM)
packages = event.get("packages", [])
# ... SSH + установка ...
return {"status": "ok", "installed": packages}
```
---
## Спецификация каждой функции
### 1. install-packages (Этап A — первый)
**Назначение:** Универсальный apt-установщик. Ставит произвольный список пакетов.
**handler.py** — entrypoint: `handler.install`
```
event_json:
packages: ["git", "curl", "htop", "..."] # обязательно — список имён пакетов apt
update: true # опционально — apt update перед install (default: true)
env_vars:
VM_IP: nubes_vc_vm_v3.vm.state_out_flat["externalConnect"]
SSH_USER: "ubuntu"
SSH_KEY: file("${path.module}/vm_key")
requirements.txt:
paramiko
```
**Логика:**
1. Подключиться по SSH через paramiko (ключ из env var, не из файла на диске).
2. `sudo apt-get update` (если event.update != false).
3. `sudo DEBIAN_FRONTEND=noninteractive apt-get install -y <packages>`.
4. Проверить `dpkg -l <package>` для каждого.
5. Вернуть `{"status": "ok", "installed": [...], "already_installed": [...], "failed": [...]}`.
**Обработка ошибок:**
- SSH connection refused → retry 3 раза с шагом 10 сек (ВМ может ещё грузиться).
- apt lock → retry 5 раз с шагом 15 сек.
- Частичный фейл (2 из 5 пакетов не найдены) → status="partial", failed=[...].
- Полный фейл → raise Exception с читаемым сообщением.
**Идемпотентность:** Повторный запуск безопасен — apt-get install -y ничего не ломает.
### 2. install-docker (Этап A)
**Назначение:** Docker CE + docker-compose plugin по официальной инструкции.
**handler.py** — entrypoint: `handler.install`
```
event_json:
compose: true # опционально — ставить ли docker-compose plugin (default: true)
env_vars:
VM_IP, SSH_USER, SSH_KEY — те же
requirements.txt:
paramiko
```
**Логика:**
1. SSH → проверить `docker --version`. Если уже есть — вернуть `{"status": "already_installed", ...}`.
2. Добавить Docker apt-репозиторий (GPG ключ + sources.list).
3. `apt-get install docker-ce docker-ce-cli containerd.io`.
4. Если compose=true → `apt-get install docker-compose-plugin`.
5. `sudo usermod -aG docker $SSH_USER`.
6. Проверить: `docker run hello-world`.
7. Вернуть `{"status": "ok", "docker_version": "...", "compose": true/false}`.
**Идемпотентность:** Проверяет наличие перед установкой.
### 3. install-postgres (Этап B)
**Назначение:** PostgreSQL сервер + создание БД и пользователя.
**handler.py** — entrypoint: `handler.install`
```
event_json:
pg_version: "14" # опционально (default: "14")
db_name: "myapp" # обязательно — имя БД
db_user: "app_user" # обязательно — имя пользователя
db_password: "secure_pass" # обязательно — пароль
listen_addresses: "*" # опционально (default: "localhost")
allow_remote: true # опционально — добавлять ли в pg_hba.conf (default: false)
env_vars:
VM_IP, SSH_USER, SSH_KEY — те же
requirements.txt:
paramiko
```
**Логика:**
1. SSH → проверить `psql --version`. Если нет:
- `apt-get install postgresql postgresql-contrib postgresql-client`.
2. `systemctl is-active postgresql` — убедиться что запущен.
3. Создать пользователя: `sudo -u postgres psql -c "CREATE USER ... PASSWORD ..."` (IF NOT EXISTS).
4. Создать БД: `sudo -u postgres psql -c "CREATE DATABASE ... OWNER ..."` (IF NOT EXISTS).
5. Если allow_remote: настроить `listen_addresses` в postgresql.conf + запись в pg_hba.conf.
6. `systemctl restart postgresql` (если конфиг менялся).
7. Проверить подключение: `psql -h localhost -U <user> -d <db> -c "SELECT 1"`.
8. Вернуть `{"status": "ok", "pg_version": "14.x", "db_name": "myapp", ...}`.
**Идемпотентность:** IF NOT EXISTS для пользователя и БД. Config-записи — grep перед append.
---
## Terraform: sless.tf (скелет)
```hcl
# 2026-03-29 — sless.tf: sless_job функции для установки ПО на ВМ.
# Каждый job подключается по SSH и ставит ПО.
provider "sless" {
endpoint = "https://sless.kube5s.ru"
token = var.sless_token
}
# --- Общие локальные переменные ---
locals {
vm_ip = nubes_vc_vm_v3.vm.state_out_flat["externalConnect"]
ssh_user = "ubuntu"
ssh_key = file("${path.module}/vm_key")
# Общий набор env_vars для SSH-подключения к ВМ
ssh_env = {
VM_IP = local.vm_ip
SSH_USER = local.ssh_user
SSH_KEY = local.ssh_key
}
}
# --- 1. Базовые пакеты ---
resource "sless_job" "install_packages" {
count = var.install_packages ? 1 : 0
name = "vm-install-packages"
runtime = "python3.11"
entrypoint = "handler.install"
source_dir = "${path.module}/functions/install-packages"
env_vars = local.ssh_env
event_json = jsonencode({
packages = var.base_packages
})
run_id = var.install_run_id
wait_timeout_sec = 600
depends_on = [nubes_vc_vm_v3.vm]
}
# --- 2. Docker ---
resource "sless_job" "install_docker" {
count = var.install_docker ? 1 : 0
name = "vm-install-docker"
runtime = "python3.11"
entrypoint = "handler.install"
source_dir = "${path.module}/functions/install-docker"
env_vars = local.ssh_env
event_json = jsonencode({
compose = true
})
run_id = var.install_run_id
wait_timeout_sec = 900
depends_on = [
nubes_vc_vm_v3.vm,
sless_job.install_packages # пакеты первыми
]
}
# --- 3. PostgreSQL ---
resource "sless_job" "install_postgres" {
count = var.install_postgres ? 1 : 0
name = "vm-install-postgres"
runtime = "python3.11"
entrypoint = "handler.install"
source_dir = "${path.module}/functions/install-postgres"
env_vars = local.ssh_env
event_json = jsonencode({
pg_version = var.pg_version
db_name = var.pg_db_name
db_user = var.pg_db_user
db_password = var.pg_db_password
allow_remote = var.pg_allow_remote
})
run_id = var.install_run_id
wait_timeout_sec = 900
depends_on = [
nubes_vc_vm_v3.vm,
sless_job.install_packages # базовые пакеты первыми
]
}
```
## Terraform: variables.tf (скелет)
```hcl
# 2026-03-29 — variables.tf: все переменные для examples/VM.
# --- Nubes (уже есть, перенести из main.tf) ---
variable "api_token" { type = string; sensitive = true }
variable "vm_public_key" { type = string; sensitive = true }
# --- Sless ---
variable "sless_token" {
type = string
sensitive = true
description = "JWT-токен для sless API"
}
# --- Флаги установки ---
variable "install_packages" {
type = bool
default = true
description = "Установить базовые apt-пакеты"
}
variable "install_docker" {
type = bool
default = false
description = "Установить Docker CE"
}
variable "install_postgres" {
type = bool
default = false
description = "Установить PostgreSQL"
}
# --- Параметры ---
variable "base_packages" {
type = list(string)
default = ["git", "curl", "htop", "jq", "unzip"]
description = "Список apt-пакетов для install-packages"
}
variable "install_run_id" {
type = number
default = 1
description = "Увеличить для повторного запуска всех install-джобов"
}
# --- PostgreSQL ---
variable "pg_version" { type = string; default = "14" }
variable "pg_db_name" { type = string; default = "myapp" }
variable "pg_db_user" { type = string; default = "app_user" }
variable "pg_db_password" { type = string; sensitive = true; default = "" }
variable "pg_allow_remote" { type = bool; default = false }
```
## Terraform: outputs.tf (скелет)
```hcl
# 2026-03-29 — outputs.tf: статусы установки.
output "install_packages_result" {
value = var.install_packages ? sless_job.install_packages[0].message : "skipped"
}
output "install_docker_result" {
value = var.install_docker ? sless_job.install_docker[0].message : "skipped"
}
output "install_postgres_result" {
value = var.install_postgres ? sless_job.install_postgres[0].message : "skipped"
}
```
---
## Порядок выполнения для агента
### Фаза 1: Инфраструктура Terraform (4 файла)
1. Создать `variables.tf` — все переменные (перенести из main.tf + новые).
2. Создать `sless.tf` — провайдер sless + 3 ресурса sless_job.
3. Создать `outputs.tf` — статусы.
4. Обновить `main.tf` — вынести переменные в variables.tf, добавить required_providers sless.
> **Проверка:** `terraform validate` должен пройти.
### Фаза 2: Функция install-packages (1 функция, полный E2E)
1. Создать `functions/install-packages/handler.py`.
2. Создать `functions/install-packages/requirements.txt` (paramiko).
3. Добавить `sless_token` в `terraform.tfvars`.
4. `terraform apply` с `install_packages = true`.
5. Убедиться: `phase = Succeeded`, пакеты установлены на ВМ.
> **Это ключевой момент.** Если install-packages прошёл E2E — паттерн работает, остальные функции аналогичны.
### Фаза 3: Функции install-docker и install-postgres
1. Создать `functions/install-docker/handler.py` + `requirements.txt`.
2. Создать `functions/install-postgres/handler.py` + `requirements.txt`.
3. `terraform apply` с `install_docker = true, install_postgres = true`.
4. Проверить: Docker установлен, PostgreSQL работает, БД создана.
### Фаза 4: Полировка и README
1. Обновить `README.md` — как использовать шаблон с sless.
2. `terraform apply` с нуля (destroy + apply) — весь цикл.
---
## SSH через paramiko — референсный паттерн
```python
# Этот блок — основа для всех handler.py. Копировать и адаптировать.
import os, io, time
import paramiko
def _ssh_connect(retries=3, delay=10):
"""Подключение к ВМ по SSH. Retry при connection refused (ВМ грузится)."""
key = paramiko.Ed25519Key.from_private_key(io.StringIO(os.environ["SSH_KEY"]))
for attempt in range(retries):
try:
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(
hostname=os.environ["VM_IP"],
username=os.environ["SSH_USER"],
pkey=key,
timeout=15,
)
return client
except Exception as e:
if attempt == retries - 1:
raise RuntimeError(f"SSH connection failed after {retries} attempts: {e}")
time.sleep(delay)
def _ssh_run(client, cmd, check=True):
"""Выполнить команду. При check=True — бросить ошибку если exit_code != 0."""
stdin, stdout, stderr = client.exec_command(cmd, timeout=300)
exit_code = stdout.channel.recv_exit_status()
out = stdout.read().decode().strip()
err = stderr.read().decode().strip()
if check and exit_code != 0:
raise RuntimeError(f"Command failed (exit {exit_code}): {cmd}\nstderr: {err}")
return exit_code, out, err
```
---
## Ограничения и подводные камни
1. **SSH через внешний IP** — Sless-поды находятся в k8s кластере `kube5s.ru`, VM — в Nubes vDC.
Эти сети **не связаны напрямую**. Используем `externalConnect` (публичный IP).
`internalConnect` (`10.x.x.x`) доступен только внутри Nubes vDC — из sless-подов он недостижим.
> **TODO для DevOps облака Nubes:** обсудить организацию внутреннего трафика между
> k8s кластером и Nubes vDC — VPN/peering/dedicated link. До решения — только внешний IP.
> Когда появится внутренний маршрут — заменить `externalConnect` → `internalConnect` в locals.
В `sless.tf`:
```hcl
# TODO: заменить на internalConnect когда DevOps настроят сеть между кластером и vDC
vm_ip = nubes_vc_vm_v3.vm.state_out_flat["externalConnect"]
```
2. **SSH_KEY в env_var** — Содержимое приватного ключа передаётся как env var (строка).
paramiko умеет читать из `io.StringIO`. Не писать в файл.
2. **apt lock** — Если apt уже заблокирован (unattended-upgrades), будет ошибка.
Retry с проверкой `/var/lib/dpkg/lock-frontend`.
3. **depends_on обязателен** — ВМ должна быть готова до запуска job.
`depends_on = [nubes_vc_vm_v3.vm]`. Без этого terraform может запустить параллельно.
4. **run_id для повторного запуска** — sless_job не перезапускается автоматически.
Чтобы перезапустить: `install_run_id = 2` → `terraform apply`.
5. **wait_timeout_sec** — Kaniko-сборка + выполнение. 600с для мелких пакетов, 900с для Docker/PG.
6. **Vault пока нет** — Секреты (pg_password, ssh_key) передаются через tfvars.
Архитектура готова к замене на Vault data source позже (env_vars заполняются из vault).
7. **Drift не отслеживается** — Job-модель ≠ полноценный stateful resource.
Если кто-то удалит Docker на ВМ — Terraform не знает. Перезапуск: увеличить run_id.
-74
View File
@@ -4,80 +4,6 @@
---
## 2026-03-29 — SSH timeout после destroy VM example
### Симптом
После `terraform destroy` для [examples/VM](examples/VM) попытка зайти по SSH на target VM завершилась таймаутом:
- `ssh: connect to host 185.247.187.154 port 22: Connection timed out`
### Причина
Это ожидаемое поведение для сценария suspend: VM перестаёт отвечать по SSH после destroy, а затем поднимается обратно на `terraform apply`.
### Что сделали
- Подтвердили, что `terraform apply` после destroy восстанавливает доступ и повторно запускает install jobs.
---
## 2026-03-26 — БАГ ГЕНЕРАТОРА: modify-only поля помечаются Required в schema ресурса
```
Error: Missing required argument
on vc_org.tf line 5, in resource "nubes_vc_org" "dev_org":
5: resource "nubes_vc_org" "dev_org" {
The argument "v_i_p_configure" is required, but no definition was found.
The argument "resource_name" is required, but no definition was found.
```
### Причина
Генератор (`~/terra/terraform/devops/`) при создании Go-кода ресурса (`19_vc_org_resource.go`)
помечает **все операции ресурса** как `Required` в схеме, включая поля,
которые нужны только для операции `modify` (не для `create`).
Конкретный пример: `vIPConfigure` (код параметра 662) — это поле операции `modify`,
но попадает в schema с `Required: true`:
```go
"v_i_p_configure": schema.StringAttribute{Required: true},
```
В YAML-описании сервиса (19_vc_org.yaml) `vIPConfigure` объявлен только под
`operations.modify.params`, а не под `operations.create.params`.
### Что нужно исправить в генераторе
В `~/terra/terraform/devops/` (файлы `02_generate_resources_and_docs*.go/sh`):
Поля, принадлежащие только операции `modify` (или другим не-create операциям),
должны генерироваться как **`Optional: true, Computed: true`**, а не `Required: true`.
Логика:
- поле в `operations[create].params` → `Required: true`
- поле только в `operations[modify].params` → `Optional: true, Computed: true`
- поле только в state (read-only) → `Computed: true`
### Временный workaround (действующий)
В `terraform.tf`-манифесте указывать пустую строку:
```hcl
v_i_p_configure = "" # modify-only поле; при create не отправляется в API
```
Провайдер при Create не передаёт это поле в API (строка 418/556 params),
но schema.Required требует non-null значение в плане.
### Файлы для правки
- `~/terra/terraform/devops/profiles/test/generated/go/19_vc_org_resource.go` — сгенерированный, не менять вручную
- **Править нужно шаблоны/генераторы** в `~/terra/terraform/devops/`
---
## 2026-03-22 — БАГ: CreateService/CreateFunction возвращает 409 при `terraform apply -replace` (ИСПРАВЛЕН)
### Симптом
+1 -184
View File
@@ -1,189 +1,6 @@
# Прогресс разработки
Последнее обновление: 2026-03-30
---
## 2026-03-30 — Автоматизация VM stress-тестирования
### v2 (READ-ONLY) — переписан после инцидента с потерей tfvars
**Инцидент:** v1 скрипта содержал `write_tfvars()` которая перезаписывала `terraform.tfvars`.
Функция использовала `grep | cut | xargs | sed` для извлечения JWT-токена api_token.
Пайплайн не справился с длинным JWT (1200+ символов) и токен был потерян.
Это сломало весь terraform и потребовало ручного восстановления.
**Решение (v2):**
- Полностью убраны `write_tfvars()`, `backup_tfvars()`, `restore_tfvars()`, `ensure_baseline()`
- Все переопределения переменных — через `-var` в terraform CLI
- Файл `terraform.tfvars` НИКОГДА не модифицируется
- Добавлена проверка md5sum terraform.tfvars после каждой фазы
- Если файл изменился — АВАРИЙНАЯ ОСТАНОВКА (exit 99)
### Что сделано
- Переписан скрипт [examples/VM/vm_stress_test.sh](examples/VM/vm_stress_test.sh) (v2, 847 строк, 10 фаз)
- Создана инструкция [examples/VM/VM_TEST_README.md](examples/VM/VM_TEST_README.md)
- Старая версия сохранена как `.vm_stress_test.sh.OLD`
### Сценарии (10 фаз):
1. **Baseline**: apply с полным набором (packages+nginx+docker)
2. **Idempotent**: plan → "No changes"
3. **Partial Disable**: выключить nginx+docker через `-var`
4. **Partial Enable**: включить обратно
5. **Reorder Packages**: изменить base_packages через `-var`
6. **Manual Purge**: удалить пакеты с VM по SSH → переустановить
7. **Destroy**: terraform destroy → VM suspend
8. **Resurrect**: apply после destroy
9. **Stress Cycles**: N циклов destroy/apply
10. **Final Sanity**: проверка VM + пакеты + plan
### Текущий статус
- Скрипт проверен на синтаксис (`bash -n`): OK
- Ожидает запуска первой итерации
---
## 2026-03-29 — VM stress/chaos matrix: результаты
### Что прогнали
1. Ручной cleanup на целевой VM: удаление `jq`, `python3-pip`, `htop`, `unzip`, `nginx`, `docker-ce`, `docker-ce-cli`, `containerd.io`, `docker-compose-plugin`.
2. `terraform destroy` на [examples/VM](examples/VM).
3. Проверка SSH-доступа после destroy.
4. `terraform apply` после destroy с восстановлением VM и jobs.
5. Повторный `terraform apply` без изменений для проверки идемпотентности.
6. Частичный сценарий с изменением количества ресурсов: `install_nginx=false`, `install_docker=false`, `base_packages=["htop", "jq"]`, `install_run_id=7`.
7. Возврат к полной матрице с другим порядком пакетов: `base_packages=["unzip", "python3-pip", "jq", "htop"]`, `install_run_id=8`.
8. Stress loop из 2 подряд идущих циклов `destroy -> apply`.
### Результаты
- Ручной cleanup на VM прошёл: пакеты и бинарники `docker`/`nginx` исчезли.
- `terraform destroy` завершился успешно и вывел `Destroy complete! Resources: 5 destroyed.`
- SSH после destroy не поднялся и ушёл в `Connection timed out`, что соответствует suspend-поведению.
- `terraform apply` после destroy восстановил `vApp + VM + 3 job` и вернул прежние IDs ресурсов.
- Повторный `apply` без изменений дал `No changes`.
- Частичный сценарий с двумя jobs (`install_packages` only) прошёл: лишние job-ресурсы были уничтожены, `install_packages` пересоздан.
- Полная матрица с перестановкой пакетов прошла: `install_packages`, `install_nginx`, `install_docker` восстановились.
- Stress loop из 2 циклов `destroy -> apply` завершился без ошибок.
### Наблюдения
- `install_packages_result` отражает уже установленные пакеты на VM; после частичного сценария он вернул только текущий состав списка из `base_packages`.
- `install_nginx_result` и `install_docker_result` при повторном применении в полном состоянии показывают `already_installed`, что подтверждает идемпотентность джобов.
- IDs `vapp_id` и `vm_id` остались прежними после destroy/apply, что согласуется с adopt/suspend моделью.
## 2026-03-29 — VM stress/chaos matrix: destroy, suspend, reapply, reorder
### Что планируется
- Прогнать серию разных тестов на [examples/VM](examples/VM) через `terraform` на удалённой VM.
- Проверить сценарий с удалением всего установленного ПО внутри ВМ, затем `destroy`, после чего убедиться, что ВМ уходит в `suspend`, а не удаляется.
- Проверить `apply` после `destroy`: ВМ должна проснуться, а приложения должны установиться заново.
- Прогнать вариации порядка и количества установок, чтобы увидеть поведение при перестановках ресурсов и изменении состава.
- Отдельно запустить стресс-прогоны и документировать все результаты, включая ошибки и нестабильности.
### Что будет фиксироваться
- Команды и их итоговый статус.
- Любые расхождения между планом и фактическим состоянием ВМ.
- Ошибки `terraform`, `ssh` и установки пакетов.
- Поведение suspend/resume и повторной установки после `apply`.
## 2026-03-28 — Handoff: функции-джобы для установки ПО в ВМ (курс на PostgreSQL)
### Контекст и цель
- Пример [examples/VM](examples/VM) рассматривается как пользовательский Terraform-шаблон.
- Пользовательский сценарий: скачать шаблон, подставить токены/ключи, выбрать нужные пакеты, выполнить `terraform apply`.
- Целевое поведение: один apply поднимает `vApp + VM` и запускает автоматическую установку ПО в VM.
- Vault в облаке пока недоступен, но архитектура должна быть ready для последующего перехода на Vault без переписывания логики функций.
### Что выяснили по текущей платформе (sless)
- Для one-shot действий в sless уже есть подходящая сущность: `sless_job`.
- Модель запуска:
1. Создаётся job-ресурс.
2. Загружается исходник функции (`/upload`).
3. Оператор собирает образ (kaniko).
4. Job запускается в k8s, статус виден как `Pending/Building/Running/Succeeded/Failed`.
- Передача входных параметров:
1. `event_json` -> payload в `handle(event)`.
2. `env_vars` -> переменные окружения внутри контейнера.
- Ошибки установки отслеживаются на нескольких уровнях:
1. Terraform apply (resource fail).
2. Статус job (`Phase`, `Message`).
3. Логи пода/контейнера (детали SSH/apt/команд).
### Архитектурное решение на сейчас
- Не делать отдельный новый Terraform resource под установку пакетов на текущем этапе.
- Использовать `sless_job` как основной механизм выполнения.
- Причина: установка ПО по SSH в VM - это execution-задача (one-shot), а не устойчивый ресурс со сложной моделью state/drift.
- Отдельный resource рассматривать позже, когда стабилизируется контракт (semantics ensure-present/absent/version + read/drift).
### Принятый целевой дизайн (этап 1)
1. Универсальная функция-джоб `install-packages`.
2. Отдельные специализированные функции-джобы: `install-docker`, `install-git`, далее по необходимости.
3. В Terraform-шаблоне флаги/переменные включают нужные джобы.
4. Общая схема параметров:
: `event_json` для бизнес-параметров (список пакетов, режимы).
: `env_vars` для подключения к VM (`VM_IP`, `SSH_USER`, `SSH_KEY`).
### План по PostgreSQL-направлению (что делать дальше)
#### Этап A: Базовый VM bootstrap
1. Реализовать `install-packages` (apt update/install, идемпотентность, явные коды ошибок).
2. Реализовать `install-git` как отдельный job-шаблон.
3. Реализовать `install-docker` как отдельный job-шаблон (репозиторий/GPG, проверка `docker --version`).
#### Этап B: PostgreSQL-specific функции
1. Добавить `install-postgres` job:
: установка `postgresql`, `postgresql-contrib`, `postgresql-client`.
: проверка статуса `systemctl is-active postgresql`.
2. Добавить `configure-postgres` job:
: создание БД/пользователя.
: настройка доступа (минимально безопасная, через параметры).
: проверка подключения `psql`.
3. Добавить `seed-postgres` job (опционально):
: создание таблиц/базовых данных для демо.
#### Этап C: Terraform UX для пользователя шаблона
1. Вынести управляемые параметры в `terraform.tfvars`:
: `install_packages`, `install_docker`, `install_git`, `install_postgres`.
: `postgres_db`, `postgres_user` и др. параметры.
2. Обеспечить зависимости:
: VM должна быть готова до старта job.
: Postgres-конфиг запускается после установки postgres.
3. Добавить outputs с итоговым статусом job-ов для быстрого контроля.
#### Этап D: Переход на Vault (когда сервис появится)
1. Не менять код функций.
2. Заменить только источник секретов в Terraform (`env_vars` заполняются из Vault data source).
3. Сохранить обратную совместимость с текущим режимом (секрет в tfvars) для dev/demo.
### Риски и ограничения, зафиксированные заранее
1. SSH/apt операции подвержены временным сетевым сбоям и lock-файлам apt -> нужны retries и читаемые сообщения об ошибках.
2. Job-модель не равна полноценному stateful resource: drift пакетов в VM не отслеживается автоматически Terraform-ом.
3. Для production-пути позже потребуется отдельный контракт безопасности по секретам и ротации ключей.
### Что уже сделано в этой ветке перед handoff
1. Обновлён пример VM по nubes provider `5.0.49`.
2. Переименованы имена VM/vApp ресурсов в более короткий формат (`vm-sless`, `vapp-sless`).
3. Изменения закоммичены и отправлены в ветку `examples/dev-from-ground`.
### Рекомендация для нового чата
1. Стартовать реализацию с `install-packages` + Terraform wiring в [examples/VM](examples/VM).
2. После успешного E2E добавить `install-postgres` и `configure-postgres`.
3. Держать код максимально идемпотентным, чтобы повторный apply не ломал VM.
Последнее обновление: 2026-03-23
---
-28
View File
@@ -1,28 +0,0 @@
// 2026-03-26 — main.tf: провайдер Nubes для DEV-стенда.
// DEV API endpoint: https://deck-api-dev.ngcloud.ru/api/v1
// Токен: secrets/dev.token (tazet@narod.ru)
terraform {
required_providers {
nubes = {
source = "terra.k8c.ru/nubes/nubes"
version = "5.0.31"
}
}
}
variable "api_token" {
type = string
sensitive = true
description = "Nubes API токен (DEV-стенд). Значение — в terraform.tfvars."
}
variable "resource_realm" {
type = string
description = "Платформа развёртывания (например k8s-3.ext.nubes.ru). Уточнить у сервис-менеджера."
}
provider "nubes" {
api_token = var.api_token
api_endpoint = "https://deck-api-dev.ngcloud.ru/api/v1/index.cfm"
}
-34
View File
@@ -1,34 +0,0 @@
// 2026-03-26 — vc_org.tf: ресурс «Организация в Cloud Director» для DEV-стенда.
// nubes_vc_org — тенант vCloud Director (organization_type = "iaas").
// resource_realm задаётся через переменную (terraform.tfvars или -var).
resource "nubes_vc_org" "dev_org" {
resource_name = "vcOrg-2"
resource_realm = var.resource_realm
# organization_type "iaas" — единственный вариант с доступом к организации.
# Значение по умолчанию "iaas", явно прописано для читаемости.
organization_type = "iaas"
# v_i_p_configure — JSON-список ipSpaces для операции modify.
# При create провайдер не передаёт его в API, но требует non-null значение в плане.
v_i_p_configure = ""
# adopt_existing_on_create = true — берёт существующий инстанс (dev-org-sless-demo уже создан с null realm от предыдущей попытки).
adopt_existing_on_create = true
# suspend_on_destroy = true (по умолчанию) — при destroy инстанс уходит в Suspend, не удаляется.
suspend_on_destroy = true
}
# ─── Outputs ─────────────────────────────────────────────────────────────────
output "dev_org_id" {
description = "ID созданной организации (используется в зависимых ресурсах)"
value = nubes_vc_org.dev_org.id
}
output "dev_org_state_flat" {
description = "Плоский state организации — endpoints, статусы"
value = nubes_vc_org.dev_org.state_out_flat
}
-32
View File
@@ -1,32 +0,0 @@
// Создано: 2026-03-23
// main.tf — провайдер Nubes + переменные для примера NODEJS.
// Ресурс nubes_nodejs: managed Node.js приложение в облаке (не sless-функция).
terraform {
required_providers {
nubes = {
source = "terra.k8c.ru/nubes/nubes"
version = "5.0.19"
}
}
}
variable "api_token" {
type = string
sensitive = true
}
variable "realm" {
type = string
description = "resource_realm — зона размещения ресурса (например: k8s-3-sandbox-nubes-ru)"
}
variable "git_path" {
type = string
description = "URL git-репозитория с кодом приложения"
}
provider "nubes" {
api_token = var.api_token
api_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm"
}
-22
View File
@@ -1,22 +0,0 @@
# Создано: 2026-03-23
# nodejs.tf — ресурс nubes_nodejs: managed Node.js приложение.
# Параметры взяты из документации terra.k8c.ru/docs/nubes/nubes/5.0.19/30_registry/resources/nodejs_params_create/
resource "nubes_nodejs" "app" {
resource_name = "nodejsdemo1"
domain = "domma"
resource_realm = var.realm
git_path = var.git_path
app_version = "23"
resource_c_p_u = 500
resource_memory = 1024
resource_instances = 1
json_env = jsonencode({})
adopt_existing_on_create = true
# health_path не задан — используется дефолтный /
}
output "nodejs_domain" {
description = "Домен развёрнутого Node.js приложения"
value = nubes_nodejs.app.domain
}
@@ -1,9 +0,0 @@
// Создано: 2026-04-10
// Демо-функция: возвращает текущее время сервера.
// Юзер меняет код под себя и перебилдит через terraform apply.
'use strict';
module.exports.handler = function handler(event) {
return `Текущее время: ${new Date().toISOString()}`;
};
@@ -1,3 +0,0 @@
{
"dependencies": {}
}
@@ -1,105 +0,0 @@
# Создано: 2026-04-10
# Изменено: 2026-03-23 — упрощён до поля ввода выражения (демонстрация деплоя).
# Принимает произвольное математическое выражение: "2+2*(3-1)", "(10/3)**2" и т.д.
# GET → HTML страница с формой; POST с {expr} → вычисление через безопасный eval.
# Безопасность eval: __builtins__=None, только math-функции в locals.
import math
_PAGE = """<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Калькулятор — Python 3.11</title>
<style>
body { font-family: monospace; background: #0f172a; color: #e2e8f0;
display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; }
.box { background: #1e293b; border-radius: 12px; padding: 32px; width: 420px; box-shadow: 0 8px 32px #0005; }
h2 { margin: 0 0 4px; font-size: 20px; color: #7dd3fc; }
.sub { color: #475569; font-size: 12px; margin-bottom: 24px; }
input { width: 100%; box-sizing: border-box; padding: 10px 14px; font-size: 18px; font-family: monospace;
background: #0f172a; border: 1px solid #334155; border-radius: 8px; color: #f1f5f9; outline: none; }
input:focus { border-color: #38bdf8; }
button { margin-top: 12px; width: 100%; padding: 12px; font-size: 16px; background: #0369a1;
color: #fff; border: none; border-radius: 8px; cursor: pointer; }
button:hover { background: #0284c7; }
button:disabled { background: #1e3a5f; color: #475569; cursor: default; }
.result { margin-top: 20px; padding: 14px; border-radius: 8px; font-size: 22px; text-align: center; display: none; }
.ok { background: #064e3b; color: #6ee7b7; display: block; }
.err { background: #450a0a; color: #fca5a5; font-size: 14px; display: block; }
</style>
</head>
<body>
<div class="box">
<h2>Калькулятор</h2>
<div class="sub">Python 3.11 · runtime: sless</div>
<input id="expr" autofocus placeholder="например: 2 + 2 * (3 - 1)">
<button id="btn" onclick="calc()">Вычислить</button>
<div id="result" class="result"></div>
</div>
<script>
document.getElementById('expr').addEventListener('keydown', function(e) {
if (e.key === 'Enter') calc();
});
async function calc() {
const expr = document.getElementById('expr').value.trim();
if (!expr) return;
const btn = document.getElementById('btn');
const res = document.getElementById('result');
btn.disabled = true;
btn.textContent = '…';
try {
const r = await fetch('', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({expr: expr})
});
const data = await r.json();
if (data.error) {
res.className = 'result err';
res.textContent = data.error;
} else {
res.className = 'result ok';
res.textContent = expr + ' = ' + data.result;
}
} catch(e) {
res.className = 'result err';
res.textContent = 'Ошибка сети: ' + e.message;
}
btn.disabled = false;
btn.textContent = 'Вычислить';
}
</script>
</body>
</html>"""
# Разрешённые math-функции в eval — без __builtins__ нет доступа к exec/open/etc.
_MATH_LOCALS = {k: getattr(math, k) for k in dir(math) if not k.startswith('_')}
def handler(event):
if event.get('_method') == 'POST':
expr = str(event.get('expr', '')).strip()
return _compute(expr)
# GET → HTML страница
return _PAGE
def _compute(expr):
if not expr:
return {'error': 'Введите выражение'}
try:
result = eval(expr, {'__builtins__': None}, _MATH_LOCALS) # noqa: S307
if not isinstance(result, (int, float)):
return {'error': 'Результат не является числом'}
return {'expr': expr, 'result': result}
except ZeroDivisionError:
return {'error': 'Деление на ноль'}
except Exception as exc:
return {'error': f'Ошибка: {exc}'}
def _esc(s):
# Экранируем HTML-спецсимволы — безопасный вывод в атрибут и тело.
return s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')
@@ -1 +0,0 @@
# нет внешних зависимостей
-36
View File
@@ -1,36 +0,0 @@
# Создано: 2026-04-10
# functions.tf — sless_service ресурсы для примера POSTGRES.
# Здесь: два калькуляторa — Python и Node.js.
# sless_service = long-running Deployment + постоянный URL (в отличие от sless_function).
# ─── Python-калькулятор ──────────────────────────────────────────────────────
resource "sless_service" "calc_python" {
name = "calc-python"
runtime = "python3.11"
entrypoint = "handler.handler"
memory_mb = 128
timeout_sec = 30
source_dir = "${path.module}/code/calc-python"
}
output "calc_python_url" {
description = "URL Python-калькулятора"
value = sless_service.calc_python.url
}
# ─── Node.js-калькулятор ─────────────────────────────────────────────────────
resource "sless_service" "calc_node" {
name = "calc-node"
runtime = "nodejs20"
entrypoint = "handler.handler"
memory_mb = 128
timeout_sec = 30
source_dir = "${path.module}/code/calc-node"
}
output "calc_node_url" {
description = "URL Node.js-калькулятора"
value = sless_service.calc_node.url
}
+1 -1
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
nubes = {
source = "terra.k8c.ru/nubes/nubes"
version = "5.0.31"
version = "5.0.19"
}
sless = {
source = "terra.k8c.ru/naeel/sless"
-204
View File
@@ -1,204 +0,0 @@
# Пример: Виртуальная машина (vApp + VM) в Nubes vDC
Создаёт:
- **vApp** — виртуальный каталог (контейнер для ВМ в VMware vDC)
- **ВМ** — Ubuntu 22.04, 2 CPU / 2 GB RAM / 20 GB disk
- **Serverless-джобы** — устанавливают ПО на ВМ по SSH после создания
---
## Быстрый старт
```bash
cp terraform.tfvars.template terraform.tfvars
# Заполни terraform.tfvars (инструкция ниже)
terraform init
terraform apply
```
---
## Шаг 1 — Получить данные из Личного Кабинета
### API-токен
> Личный Кабинет → правый верхний угол → **«Профиль»** → **«API-токены»** → **«Создать токен»**
Скопируйте JWT-строку целиком (`eyJhbGciOiJS...`).
Один токен работает для обоих провайдеров — nubes (облако) и sless (serverless).
### UUID сервисов (vdc_uid и nsxt_uid)
> Личный Кабинет → **«Мои сервисы»** → нужный сервис → **«Параметры инстанса»** → поле UUID
| Параметр | Что искать в ЛК |
|---|---|
| `vdc_uid` | Сервис **«Виртуальный датацентр (vDC)»** → UUID |
| `nsxt_uid` | Сервис **«Сетевой шлюз периметра (Edge)»** → UUID |
UUID выглядит так: `e3c9e4f1-24da-4992-a003-f8a2a803a5f0`
> **Важно:** `vdc_uid` и `nsxt_uid` **не изменяются после первого `terraform apply`**.
> Менять их нельзя — сломается terraform state.
---
## Шаг 2 — Сгенерировать SSH-ключ для ВМ
Публичный ключ прописывается в ВМ при создании — это **единственный** способ зайти по SSH.
```bash
# Выполнить в папке examples/VM/
ssh-keygen -t ed25519 -f ./vm_key -N "" -C "sless-demo-vm"
```
Создаст два файла: `vm_key` (приватный) и `vm_key.pub` (публичный).
---
## Шаг 3 — Заполнить terraform.tfvars
```bash
cp terraform.tfvars.template terraform.tfvars
```
Открыть `terraform.tfvars` и заполнить:
```hcl
api_token = "eyJhbGciOiJS..." # из ЛК (шаг 1)
vm_public_key = "ssh-ed25519 AAAA..." # содержимое vm_key.pub (шаг 2)
vdc_uid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" # из ЛК (шаг 1)
nsxt_uid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" # из ЛК (шаг 1)
```
Остальные параметры (`install_packages`, `base_packages` и т.д.) можно менять в любое время.
---
## Запуск
```bash
terraform init
terraform apply
```
После успешного `apply` Terraform выведет:
```
Outputs:
vm_id = "..."
vm_state = {
"externalIp" = "1.2.3.4"
...
}
vapp_id = "..."
```
---
## Подключение по SSH
```bash
ssh -i ./vm_key ubuntu@<externalIp из vm_state>
```
Логин всегда `ubuntu`.
---
## Управление установкой ПО
Установка выполняется через serverless-джобы — Terraform запускает k8s Job, который подключается к ВМ по SSH и устанавливает пакеты.
### Флаги установки (в terraform.tfvars)
| Переменная | Что делает | По умолчанию |
|---|---|---|
| `install_packages` | Устанавливает пакеты из `base_packages` | `true` |
| `install_nginx` | Устанавливает nginx | `true` |
| `install_docker` | Устанавливает Docker CE + docker-compose-plugin | `true` |
### Как изменить список пакетов
В `terraform.tfvars`:
```hcl
base_packages = ["jq", "htop", "curl", "git", "python3-pip"]
```
Любые стандартные apt-пакеты Ubuntu 22.04.
После изменения — увеличьте `install_run_id` и выполните `terraform apply`.
### Как перезапустить установку
sless_job — разовый джоб. При повторном `apply` Terraform не перезапускает его если ничего не изменилось.
Чтобы запустить все install-джобы заново — увеличьте `install_run_id` на 1:
```hcl
# было:
install_run_id = 3
# стало:
install_run_id = 4
```
Затем `terraform apply`. Установка идемпотентна — повторное выполнение не ломает систему.
### Как отключить отдельный компонент
```hcl
install_docker = false # не устанавливать Docker
```
После `apply` ресурс `sless_job.install_docker` будет удалён из state.
Docker на уже созданной ВМ останется — Terraform не удаляет пакеты.
---
## Удаление
```bash
terraform destroy
```
Порядок автоматический: сначала suspend → потом delete.
Параметр `suspend_on_destroy = true` решает это — без него удаление упадёт с ошибкой Nubes _«Услуга не остановлена»_.
---
## Справочник параметров
### Можно менять в любое время
| Параметр | Файл | Эффект |
|---|---|---|
| `vm_cpu`, `vm_ram`, `vm_disk` | `vm.tf` | ВМ будет изменена |
| `install_packages/nginx/docker` | `terraform.tfvars` | Джоб добавится или удалится |
| `base_packages` | `terraform.tfvars` | Пакеты изменятся — увеличить `install_run_id` + apply |
| `install_run_id` | `terraform.tfvars` | Перезапускает все install-джобы |
### Нельзя менять после первого apply
| Параметр | Файл | Причина |
|---|---|---|
| `vdc_uid`, `nsxt_uid` | `terraform.tfvars` | Идентифицируют сервисы в terraform state |
| `resource_name`, `vapp_name` | `vapp.tf` | Уникальные имена ресурсов в Nubes |
| `image_vm`, `user_login` | `vm.tf` | Неизменяемые параметры ВМ |
| `vm_public_key` | `terraform.tfvars` | Прописывается в ВМ один раз при создании |
---
## Файлы проекта
| Файл | Назначение |
|---|---|
| `terraform.tfvars.template` | **Шаблон** — скопировать в `terraform.tfvars` и заполнить |
| `terraform.tfvars` | Ваши значения (не в git — содержит секреты) |
| `main.tf` | Провайдеры + переменные `api_token` и `vm_public_key` |
| `variables.tf` | Все остальные переменные с описаниями |
| `vapp.tf` | Ресурс vApp (контейнер ВМ) |
| `vm.tf` | Ресурс ВМ (Ubuntu 22.04) |
| `sless.tf` | Serverless-джобы для установки ПО |
| `outputs.tf` | Вывод IP-адреса и ID ресурсов |
| `vm_key` / `vm_key.pub` | SSH-ключ для доступа к ВМ |
| `functions/` | Код Python-функций для install-джобов |
-106
View File
@@ -1,106 +0,0 @@
# VM Stress Test — Инструкция по запуску
# 2026-03-30
## ⛔⛔⛔ КРИТИЧЕСКИЕ ПРАВИЛА ⛔⛔⛔
### ЗАПРЕЩЕНО (без исключений):
- **НЕ РЕДАКТИРОВАТЬ** `terraform.tfvars` — там JWT-токен, потеря = катастрофа
- **НЕ РЕДАКТИРОВАТЬ** `*.tf` файлы
- **НЕ РЕДАКТИРОВАТЬ** `vm_stress_test.sh`
- **НЕ ЗАПУСКАТЬ** `terraform` напрямую — только через скрипт
- **НЕ СОЗДАВАТЬ** новые файлы в этой директории
- **НЕ ДЕЛАТЬ** `sed`, `awk`, `cat >`, `tee` в terraform.tfvars
### ПОЧЕМУ:
Предыдущая версия скрипта содержала функцию `write_tfvars()` которая
перезаписывала `terraform.tfvars`. В процессе перезаписи был потерян
JWT-токен `api_token` (1200+ символов). Это привело к полному отказу
terraform и потере рабочего состояния. Восстановление заняло час.
### КАК РАБОТАЕТ НОВЫЙ СКРИПТ:
Переменные переопределяются через `-var` в terraform CLI.
Файл `terraform.tfvars` читается terraform автоматически,
но **НИКОГДА не перезаписывается** скриптом.
После каждой фазы проверяется md5sum terraform.tfvars.
Если файл изменился — **АВАРИЙНАЯ ОСТАНОВКА** (exit code 99).
---
## Запуск
### На VM (naeel@5.172.178.213):
```bash
cd ~/terra/sless/examples/VM
bash vm_stress_test.sh 2>&1 | tee /tmp/vm_stress_$(date +%Y%m%d_%H%M).log
```
### Быстрый прогон (без destroy/resurrect — фазы 7-9 пропускаются):
```bash
SKIP_DESTROY=1 bash vm_stress_test.sh 2>&1 | tee /tmp/vm_stress.log
```
### Количество stress-циклов (default: 2):
```bash
STRESS_CYCLES=3 bash vm_stress_test.sh 2>&1 | tee /tmp/vm_stress.log
```
---
## Анализ результатов
### Быстрый обзор:
```bash
grep -E '\[(PASS|FAIL|SKIP)\]' /tmp/vm_stress.log
```
### Только ошибки:
```bash
grep '\[FAIL\]' /tmp/vm_stress.log
```
### Итоговая сводка — последние 20 строк лога:
```bash
tail -20 /tmp/vm_stress.log
```
---
## Фазы теста
| # | Имя | Что делает |
|---|-----------------|---------------------------------------------------|
| 1 | BASELINE | apply с полным набором (packages+nginx+docker) |
| 2 | IDEMPOTENT | plan → "No changes" (проверка идемпотентности) |
| 3 | PARTIAL_DISABLE | отключить nginx + docker через -var |
| 4 | PARTIAL_ENABLE | включить обратно nginx + docker |
| 5 | REORDER_PACKAGES| изменить набор base_packages через -var |
| 6 | MANUAL_PURGE | удалить пакеты с VM по SSH → переустановить |
| 7 | DESTROY | terraform destroy → VM в suspend |
| 8 | RESURRECT | apply после destroy → VM просыпается |
| 9 | STRESS_CYCLES | N циклов destroy/apply подряд |
|10 | FINAL_SANITY | финальная проверка VM + пакеты + plan |
---
## Текущее состояние (baseline)
5 ресурсов в state:
- `nubes_vapp.vapp`
- `nubes_vc_vm_v3.vm`
- `sless_job.install_packages[0]`
- `sless_job.install_nginx[0]`
- `sless_job.install_docker[0]`
---
## Exit codes
| Code | Значение |
|------|---------------------------------------------|
| 0 | Все тесты PASS |
| 1 | Есть FAIL (см. лог) |
| 99 | terraform.tfvars был изменён — АВАРИЙНЫЙ СТОП |
@@ -1,158 +0,0 @@
# 2026-03-29 — handler.py: установка Docker CE на ВМ по SSH.
# sless_job runtime: python3.11, entrypoint: handler.install
#
# Метод установки: официальный Docker apt-репозиторий (best practices).
# НЕ используется curl | sh — небезопасно для продакшена.
#
# event_json:
# compose: true/false — ставить ли docker-compose-plugin (default: true)
#
# env_vars:
# VM_IP: внешний IP ВМ
# SSH_USER: логин (ubuntu)
# SSH_KEY: содержимое приватного SSH-ключа (PEM)
import os, io, time
import paramiko
def _load_key(content):
for cls in (paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey):
try:
return cls.from_private_key(io.StringIO(content))
except Exception:
pass
raise ValueError("Неподдерживаемый тип SSH-ключа")
def _ssh_connect(retries=5, delay=10):
key = _load_key(os.environ["SSH_KEY"])
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
last_err = None
for attempt in range(retries):
try:
client.connect(
hostname=os.environ["VM_IP"],
username=os.environ["SSH_USER"],
pkey=key,
timeout=15,
)
return client
except Exception as e:
last_err = e
if attempt < retries - 1:
time.sleep(delay)
raise RuntimeError(f"SSH не удалось после {retries} попыток: {last_err}")
def _run(client, cmd, timeout=120, check=True):
_, stdout, stderr = client.exec_command(cmd, timeout=timeout)
code = stdout.channel.recv_exit_status()
out = stdout.read().decode(errors="replace").strip()
err = stderr.read().decode(errors="replace").strip()
if check and code != 0:
raise RuntimeError(f"Ошибка (exit {code}):\n{cmd}\nstderr: {err}")
return code, out, err
def _wait_apt_lock(client, attempts=20, delay=10):
"""Ждать завершения cloud-init и убить авто-обновления. Ubuntu 22.04+."""
# Шаг 1: Ждём завершения cloud-init — он держит apt при первом старте VM
_run(client, "timeout 300 sudo cloud-init status --wait 2>/dev/null; true", check=False, timeout=310)
# Шаг 2: Mask (не просто disable) — systemd не сможет перезапустить
_run(client, "sudo systemctl mask unattended-upgrades apt-daily.service apt-daily-upgrade.service apt-daily.timer apt-daily-upgrade.timer 2>/dev/null; true", check=False)
_run(client, "sudo systemctl stop unattended-upgrades apt-daily.service apt-daily-upgrade.service 2>/dev/null; true", check=False)
# Шаг 3: Добить оставшиеся apt/dpkg процессы
_run(client, "sudo pkill -9 -x unattended-upgrades apt-get apt dpkg 2>/dev/null; true", check=False)
_run(client, "sudo kill -9 $(sudo lsof -t /var/lib/dpkg/lock-frontend 2>/dev/null) 2>/dev/null; true", check=False)
# Шаг 4: Убрать стейл-локи и починить dpkg
_run(client, "sudo rm -f /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/cache/apt/archives/lock /var/lib/apt/lists/lock 2>/dev/null; true", check=False)
_run(client, "sudo dpkg --configure -a 2>/dev/null; true", check=False)
time.sleep(3)
locks = ["/var/lib/dpkg/lock-frontend", "/var/lib/dpkg/lock", "/var/lib/apt/lists/lock"]
for i in range(attempts):
all_free = all(
_run(client, f"sudo flock -n {lock} true 2>/dev/null", check=False)[0] == 0
for lock in locks
)
if all_free:
return
_run(client, "sudo pkill -9 -x apt-get apt dpkg 2>/dev/null; true", check=False)
_run(client, "sudo kill -9 $(sudo lsof -t /var/lib/dpkg/lock-frontend 2>/dev/null) 2>/dev/null; true", check=False)
if i < attempts - 1:
time.sleep(delay)
raise RuntimeError("apt lock занят слишком долго — проверьте процессы на ВМ")
# Команды установки Docker CE через официальный apt-репозиторий.
# Источник: https://docs.docker.com/engine/install/ubuntu/
_DOCKER_INSTALL_CMDS = [
# Зависимости для добавления внешнего репозитория
"sudo DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 install -y -qq ca-certificates curl gnupg",
# Директория для ключей
"sudo install -m 0755 -d /etc/apt/keyrings",
# GPG-ключ Docker
"curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor --batch --yes -o /etc/apt/keyrings/docker.gpg",
"sudo chmod a+r /etc/apt/keyrings/docker.gpg",
# Docker apt-репозиторий
(
'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] '
'https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" '
"| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null"
),
# Обновить индекс с новым репо
"sudo DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 update -qq",
# Установить Docker CE
"sudo DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 install -y -qq docker-ce docker-ce-cli containerd.io",
]
def install(event):
"""Установить Docker CE. Если уже установлен — вернуть версию."""
install_compose = event.get("compose", True)
client = _ssh_connect()
try:
# Проверить: уже установлен?
code, ver_out, _ = _run(client, "docker --version 2>&1", check=False)
if code == 0 and "Docker version" in ver_out:
_, compose_out, _ = _run(client, "docker compose version 2>&1", check=False)
return {
"status": "already_installed",
"docker_version": ver_out,
"compose_version": compose_out if "Docker Compose" in compose_out else None,
}
_wait_apt_lock(client)
for cmd in _DOCKER_INSTALL_CMDS:
_run(client, cmd, timeout=180)
if install_compose:
_run(
client,
"sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq docker-compose-plugin",
timeout=120,
)
# Добавить пользователя в группу docker (чтобы запускать без sudo)
ssh_user = os.environ["SSH_USER"]
_run(client, f"sudo usermod -aG docker {ssh_user}", check=False)
# Проверка: запустить hello-world
# Используем sudo т.к. usermod не применится до переподключения
_run(client, "sudo docker run --rm hello-world", timeout=120)
_, ver_out, _ = _run(client, "docker --version", check=False)
_, compose_out, _ = _run(client, "docker compose version 2>&1", check=False)
return {
"status": "ok",
"docker_version": ver_out,
"compose_version": compose_out if "Docker Compose" in compose_out else None,
"note": f"user '{ssh_user}' added to docker group (reconnect to use without sudo)",
}
finally:
client.close()
@@ -1,2 +0,0 @@
paramiko
# v6
@@ -1,129 +0,0 @@
# 2026-03-29 — handler.py: установка nginx на ВМ по SSH.
# sless_job runtime: python3.11, entrypoint: handler.install
#
# event_json: {} (параметров нет — nginx ставится с дефолтной конфигурацией)
#
# env_vars:
# VM_IP: внешний IP ВМ
# SSH_USER: логин (ubuntu)
# SSH_KEY: содержимое приватного SSH-ключа (PEM)
import os, io, time
import paramiko
def _load_key(content):
for cls in (paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey):
try:
return cls.from_private_key(io.StringIO(content))
except Exception:
pass
raise ValueError("Неподдерживаемый тип SSH-ключа")
def _ssh_connect(retries=5, delay=10):
key = _load_key(os.environ["SSH_KEY"])
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
last_err = None
for attempt in range(retries):
try:
client.connect(
hostname=os.environ["VM_IP"],
username=os.environ["SSH_USER"],
pkey=key,
timeout=15,
)
return client
except Exception as e:
last_err = e
if attempt < retries - 1:
time.sleep(delay)
raise RuntimeError(f"SSH не удалось после {retries} попыток: {last_err}")
def _run(client, cmd, timeout=120, check=True):
_, stdout, stderr = client.exec_command(cmd, timeout=timeout)
code = stdout.channel.recv_exit_status()
out = stdout.read().decode(errors="replace").strip()
err = stderr.read().decode(errors="replace").strip()
if check and code != 0:
raise RuntimeError(f"Ошибка (exit {code}):\n{cmd}\nstderr: {err}")
return code, out, err
def _wait_apt_lock(client, attempts=20, delay=10):
"""Ждать завершения cloud-init и убить авто-обновления. Ubuntu 22.04+."""
# Шаг 1: Ждём завершения cloud-init — он держит apt при первом старте VM
_run(client, "timeout 300 sudo cloud-init status --wait 2>/dev/null; true", check=False, timeout=310)
# Шаг 2: Mask (не просто disable) — systemd не сможет перезапустить
_run(client, "sudo systemctl mask unattended-upgrades apt-daily.service apt-daily-upgrade.service apt-daily.timer apt-daily-upgrade.timer 2>/dev/null; true", check=False)
_run(client, "sudo systemctl stop unattended-upgrades apt-daily.service apt-daily-upgrade.service 2>/dev/null; true", check=False)
# Шаг 3: Добить оставшиеся apt/dpkg процессы
_run(client, "sudo pkill -9 -x unattended-upgrades apt-get apt dpkg 2>/dev/null; true", check=False)
_run(client, "sudo kill -9 $(sudo lsof -t /var/lib/dpkg/lock-frontend 2>/dev/null) 2>/dev/null; true", check=False)
# Шаг 4: Убрать стейл-локи и починить dpkg
_run(client, "sudo rm -f /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/cache/apt/archives/lock /var/lib/apt/lists/lock 2>/dev/null; true", check=False)
_run(client, "sudo dpkg --configure -a 2>/dev/null; true", check=False)
time.sleep(3)
locks = ["/var/lib/dpkg/lock-frontend", "/var/lib/dpkg/lock", "/var/lib/apt/lists/lock"]
for i in range(attempts):
all_free = all(
_run(client, f"sudo flock -n {lock} true 2>/dev/null", check=False)[0] == 0
for lock in locks
)
if all_free:
return
_run(client, "sudo pkill -9 -x apt-get apt dpkg 2>/dev/null; true", check=False)
_run(client, "sudo kill -9 $(sudo lsof -t /var/lib/dpkg/lock-frontend 2>/dev/null) 2>/dev/null; true", check=False)
if i < attempts - 1:
time.sleep(delay)
raise RuntimeError("apt lock занят слишком долго — проверьте процессы на ВМ")
def install(event):
"""Установить nginx. Если уже установлен — проверить что запущен."""
client = _ssh_connect()
try:
# Проверить: уже установлен?
code, ver_out, _ = _run(client, "nginx -v 2>&1", check=False)
already_installed = "nginx version" in ver_out
if already_installed:
# Убедиться что сервис запущен
_run(client, "sudo systemctl start nginx", check=False)
version = ver_out.replace("nginx version: nginx/", "").strip()
_, http_code, _ = _run(
client, "curl -s -o /dev/null -w '%{http_code}' http://localhost", check=False
)
return {
"status": "already_installed",
"version": version,
"http_check": http_code,
}
_wait_apt_lock(client)
_run(client, "sudo DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 update -qq", timeout=420)
_run(
client,
"sudo DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 install -y -qq nginx",
timeout=300,
)
_run(client, "sudo systemctl enable nginx")
_run(client, "sudo systemctl start nginx")
# Проверить HTTP-ответ на localhost
_, http_code, _ = _run(
client, "curl -s -o /dev/null -w '%{http_code}' http://localhost", check=False
)
_, ver_out, _ = _run(client, "nginx -v 2>&1", check=False)
version = ver_out.replace("nginx version: nginx/", "").strip()
return {
"status": "ok",
"version": version,
"http_check": http_code,
}
finally:
client.close()
@@ -1,2 +0,0 @@
paramiko
# v6
@@ -1,133 +0,0 @@
# 2026-03-29 — handler.py: установка apt-пакетов на ВМ по SSH.
# sless_job runtime: python3.11, entrypoint: handler.install
#
# event_json:
# packages: ["git", "curl", ...] — список пакетов (обязательно)
# update: true/false — apt-get update перед install (default: true)
#
# env_vars:
# VM_IP: внешний IP ВМ
# SSH_USER: логин (ubuntu)
# SSH_KEY: содержимое приватного SSH-ключа (PEM)
import os, io, time
import paramiko
def _load_key(content):
"""Загрузить SSH-ключ (Ed25519 / RSA / ECDSA)."""
for cls in (paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey):
try:
return cls.from_private_key(io.StringIO(content))
except Exception:
pass
raise ValueError("Неподдерживаемый тип SSH-ключа")
def _ssh_connect(retries=5, delay=10):
"""Подключение к ВМ с retry — ВМ может ещё загружаться."""
key = _load_key(os.environ["SSH_KEY"])
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
last_err = None
for attempt in range(retries):
try:
client.connect(
hostname=os.environ["VM_IP"],
username=os.environ["SSH_USER"],
pkey=key,
timeout=15,
)
return client
except Exception as e:
last_err = e
if attempt < retries - 1:
time.sleep(delay)
raise RuntimeError(f"SSH не удалось после {retries} попыток: {last_err}")
def _run(client, cmd, timeout=120, check=True):
"""Выполнить команду, вернуть (exit_code, stdout, stderr)."""
_, stdout, stderr = client.exec_command(cmd, timeout=timeout)
code = stdout.channel.recv_exit_status()
out = stdout.read().decode(errors="replace").strip()
err = stderr.read().decode(errors="replace").strip()
if check and code != 0:
raise RuntimeError(f"Ошибка (exit {code}):\n{cmd}\nstderr: {err}")
return code, out, err
def _wait_apt_lock(client, attempts=20, delay=10):
"""Ждать завершения cloud-init и убить авто-обновления. Ubuntu 22.04+."""
# Шаг 1: Ждём завершения cloud-init — он держит apt при первом старте VM
_run(client, "timeout 300 sudo cloud-init status --wait 2>/dev/null; true", check=False, timeout=310)
# Шаг 2: Mask (не просто disable) — systemd не сможет перезапустить
_run(client, "sudo systemctl mask unattended-upgrades apt-daily.service apt-daily-upgrade.service apt-daily.timer apt-daily-upgrade.timer 2>/dev/null; true", check=False)
_run(client, "sudo systemctl stop unattended-upgrades apt-daily.service apt-daily-upgrade.service 2>/dev/null; true", check=False)
# Шаг 3: Добить оставшиеся apt/dpkg процессы
_run(client, "sudo pkill -9 -x unattended-upgrades apt-get apt dpkg 2>/dev/null; true", check=False)
_run(client, "sudo kill -9 $(sudo lsof -t /var/lib/dpkg/lock-frontend 2>/dev/null) 2>/dev/null; true", check=False)
# Шаг 4: Убрать стейл-локи и починить dpkg
_run(client, "sudo rm -f /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/cache/apt/archives/lock /var/lib/apt/lists/lock 2>/dev/null; true", check=False)
_run(client, "sudo dpkg --configure -a 2>/dev/null; true", check=False)
time.sleep(3)
locks = [
"/var/lib/dpkg/lock-frontend",
"/var/lib/dpkg/lock",
"/var/lib/apt/lists/lock",
]
for i in range(attempts):
all_free = all(
_run(client, f"sudo flock -n {lock} true 2>/dev/null", check=False)[0] == 0
for lock in locks
)
if all_free:
return
# Повторить убийство процессов удерживающих lock
_run(client, "sudo pkill -9 -x apt-get apt dpkg 2>/dev/null; true", check=False)
_run(client, "sudo kill -9 $(sudo lsof -t /var/lib/dpkg/lock-frontend 2>/dev/null) 2>/dev/null; true", check=False)
if i < attempts - 1:
time.sleep(delay)
raise RuntimeError("apt lock занят слишком долго — проверьте процессы на ВМ")
def install(event):
"""Установить apt-пакеты. Идемпотентно — повторный запуск безопасен."""
packages = event.get("packages", [])
if not packages:
return {"status": "skipped", "reason": "packages list is empty"}
do_update = event.get("update", True)
client = _ssh_connect()
try:
_wait_apt_lock(client)
if do_update:
_run(client, "sudo DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 update -qq", timeout=420)
pkg_str = " ".join(packages)
_run(
client,
f"sudo DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 install -y -qq {pkg_str}",
timeout=300,
)
# Проверить что установилось
installed, missing = [], []
for pkg in packages:
code, _, _ = _run(
client,
f"dpkg -l {pkg} 2>/dev/null | grep -q '^ii'",
check=False,
)
(installed if code == 0 else missing).append(pkg)
return {
"status": "ok" if not missing else "partial",
"installed": installed,
"missing": missing,
}
finally:
client.close()
@@ -1,2 +0,0 @@
paramiko
# v6
-42
View File
@@ -1,42 +0,0 @@
// 2026-03-25 — main.tf для примера с vApp + ВМ (Виртуальный датацентр Nubes).
// Провайдер nubes. Sless-провайдер не нужен — пример чисто инфраструктурный.
terraform {
required_providers {
nubes = {
source = "terra.k8c.ru/nubes/nubes"
version = "5.0.51"
}
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1"
}
}
}
# ------------------------------------------------------------------
# Переменные
# ------------------------------------------------------------------
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"
}
-18
View File
@@ -1,18 +0,0 @@
# 2026-03-29 — outputs.tf: результаты установки ПО на ВМ.
# phase: Pending / Building / Running / Succeeded / Failed
# message: JSON с деталями (что установлено) или traceback при ошибке
output "install_packages_result" {
description = "Результат установки базовых пакетов"
value = var.install_packages ? sless_job.install_packages[0].message : "skipped"
}
output "install_nginx_result" {
description = "Результат установки nginx"
value = var.install_nginx ? sless_job.install_nginx[0].message : "skipped"
}
output "install_docker_result" {
description = "Результат установки Docker"
value = var.install_docker ? sless_job.install_docker[0].message : "skipped"
}
-108
View File
@@ -1,108 +0,0 @@
# 2026-03-29 — sless.tf: провайдер sless и sless_job ресурсы для установки ПО на ВМ.
#
# Схема работы:
# 1. terraform apply создаёт FunctionJob CR в k8s
# 2. Провайдер загружает код из source_dir в S3
# 3. Оператор собирает Docker-образ (kaniko) и запускает Job
# 4. Job подключается к ВМ по SSH и устанавливает ПО
# 5. terraform apply завершается: outputs содержат статус каждого шага
#
# Для повторного запуска: увеличь install_run_id в terraform.tfvars → terraform apply
# ---------------------------------------------------------------------------
# Провайдер
# ---------------------------------------------------------------------------
provider "sless" {
endpoint = "https://sless.kube5s.ru"
token = var.api_token # тот же JWT что и в provider "nubes"
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm"
}
# ---------------------------------------------------------------------------
# Общие locals: SSH-параметры для подключения к ВМ
# ---------------------------------------------------------------------------
locals {
# TODO: заменить externalConnect → internalConnect когда DevOps настроят
# сеть между k8s кластером и Nubes vDC (сейчас только внешний IP доступен).
vm_ip = nubes_vc_vm_v3.vm.state_out_flat["externalConnect"]
ssh_env = {
VM_IP = local.vm_ip
SSH_USER = "ubuntu"
# TODO(vault): заменить на чтение из Vault когда сервис заработает; пока тестовый стенд — прямой файл.
SSH_KEY = file("${path.module}/vm_key")
}
}
# ---------------------------------------------------------------------------
# Job 1: базовые пакеты (jq, pip3 и др.)
# ---------------------------------------------------------------------------
resource "sless_job" "install_packages" {
count = var.install_packages ? 1 : 0
name = "vm-install-packages"
runtime = "python3.11"
entrypoint = "handler.install"
source_dir = "${path.module}/functions/install-packages"
memory_mb = 128
env_vars = local.ssh_env
event_json = jsonencode({
packages = var.base_packages
update = true
})
run_id = var.install_run_id
wait_timeout_sec = 600
depends_on = [nubes_vc_vm_v3.vm]
}
# ---------------------------------------------------------------------------
# Job 2: nginx
# ---------------------------------------------------------------------------
resource "sless_job" "install_nginx" {
count = var.install_nginx ? 1 : 0
name = "vm-install-nginx"
runtime = "python3.11"
entrypoint = "handler.install"
source_dir = "${path.module}/functions/install-nginx"
memory_mb = 128
env_vars = local.ssh_env
event_json = jsonencode({})
run_id = var.install_run_id
wait_timeout_sec = 600
depends_on = [nubes_vc_vm_v3.vm, sless_job.install_packages]
}
# ---------------------------------------------------------------------------
# Job 3: Docker CE
# ---------------------------------------------------------------------------
resource "sless_job" "install_docker" {
count = var.install_docker ? 1 : 0
name = "vm-install-docker"
runtime = "python3.11"
entrypoint = "handler.install"
source_dir = "${path.module}/functions/install-docker"
memory_mb = 128
env_vars = local.ssh_env
event_json = jsonencode({
compose = true
})
run_id = var.install_run_id
wait_timeout_sec = 900
depends_on = [nubes_vc_vm_v3.vm, sless_job.install_packages, sless_job.install_nginx]
}
-112
View File
@@ -1,112 +0,0 @@
# =============================================================================
# terraform.tfvars.template — шаблон конфигурации примера «ВМ в Nubes vDC»
# =============================================================================
#
# Скопируйте этот файл в terraform.tfvars и заполните все значения:
#
# cp terraform.tfvars.template terraform.tfvars
#
# terraform.tfvars НЕ коммитится в git (защищён .gitignore) —
# он содержит секретные данные (API-токен, SSH-ключ).
# =============================================================================
# =============================================================================
# 1. API-ТОКЕН
# =============================================================================
#
# Один токен для обоих провайдеров: nubes (облако) и sless (serverless).
#
# Где взять:
# Личный Кабинет Nubes → правый верхний угол → «Профиль» → «API-токены»
# → кнопка «Создать токен» → скопируйте JWT-строку целиком.
#
# Токен выглядит так: eyJhbGciOiJS...длинная строка...
# Вставьте в кавычки целиком, не разбивая на строки.
#
api_token = "ВСТАВИТЬ_API_ТОКЕН"
# =============================================================================
# 2. SSH-КЛЮЧ ДЛЯ ВМ
# =============================================================================
#
# Публичный ключ прописывается в ВМ при создании.
# Приватный ключ нужен для SSH-подключения к ВМ.
#
# Как сгенерировать:
# ssh-keygen -t ed25519 -f ./vm_key -N "" -C "sless-demo-vm"
# # Создаст два файла: vm_key (приватный) и vm_key.pub (публичный)
#
# vm_key.pub уже есть в папке — скопируйте его содержимое сюда.
# Строка выглядит так: ssh-ed25519 AAAA... имя-ключа
#
vm_public_key = "ВСТАВИТЬ_ПУБЛИЧНЫЙ_SSH_КЛЮЧ"
# =============================================================================
# 3. UUID СЕРВИСОВ NUBES (вdc_uid и nsxt_uid)
# =============================================================================
#
# Где взять:
# Личный Кабинет → «Мои сервисы» → найдите нужный сервис → раздел
# «Параметры инстанса» или «Технические параметры» → UUID.
#
# vdc_uid — это UUID услуги «Виртуальный датацентр (vDC)»
# Пример раздела ЛК: Мои сервисы → vDC → [ваш vDC] → UUID
#
# nsxt_uid — это UUID услуги «Сетевой шлюз периметра (Edge)»
# Пример раздела ЛК: Мои сервисы → Edge → [ваш Edge] → UUID
#
# Формат: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (UUID v4)
#
# ВАЖНО: эти значения не изменяются после создания vApp.
# После первого terraform apply менять их нельзя — сломает state.
#
vdc_uid = "ВСТАВИТЬ_UUID_VDC"
nsxt_uid = "ВСТАВИТЬ_UUID_NSXT"
# =============================================================================
# 4. ФЛАГИ УСТАНОВКИ ПО НА ВМ
# =============================================================================
#
# Что устанавливать при terraform apply.
# Установка выполняется через serverless-джобы (sless_job) по SSH на ВМ.
# Каждый флаг — отдельный джоб, они выполняются независимо.
#
# true = установить
# false = не устанавливать (ресурс не создаётся вовсе)
#
install_packages = true # базовые apt-пакеты из списка base_packages ниже
install_nginx = true # nginx (веб-сервер)
install_docker = true # Docker CE + docker-compose-plugin
# =============================================================================
# 5. СПИСОК БАЗОВЫХ ПАКЕТОВ
# =============================================================================
#
# Эти пакеты устанавливаются когда install_packages = true.
# Любые стандартные apt-пакеты Ubuntu 22.04.
#
# Как изменить список:
# - Добавьте пакет: base_packages = ["jq", "htop", "curl", "git"]
# - Удалите пакет: уберите его из списка
# - После изменения: увеличьте install_run_id (см. ниже) и terraform apply
#
base_packages = ["jq", "python3-pip", "htop", "unzip"]
# =============================================================================
# 6. RUN_ID — триггер повторного запуска джобов
# =============================================================================
#
# sless_job — это разовые джобы (k8s Job). Terraform не перезапускает их
# автоматически если код не изменился. Чтобы запустить ВСЕ install-джобы
# заново (например, после изменения base_packages) — увеличьте это число на 1
# и выполните terraform apply.
#
# Например: было install_run_id = 3 → стало install_run_id = 4 → apply
#
install_run_id = 1
-28
View File
@@ -1,28 +0,0 @@
// 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-vapp"
vapp_name = "vapp-sless" # Уникальное в рамках организации. Не изменяется после создания.
vdc_uid = var.vdc_uid # UUID Услуги «Виртуальный датацентр (vDC)». В terraform.tfvars.
nsxt_uid = var.nsxt_uid # UUID Услуги «Сетевой шлюз периметра (Edge)». В terraform.tfvars.
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 — адреса, статусы сети и т.д."
}
-51
View File
@@ -1,51 +0,0 @@
# 2026-03-29 — variables.tf: переменные для sless и установки ПО на ВМ.
# Переменные nubes (api_token, vm_public_key) остаются в main.tf.
# sless использует тот же api_token — отдельной переменной не нужно.
# ---- Флаги: что устанавливать на ВМ --------------------------------------
variable "install_packages" {
type = bool
default = true
description = "Установить базовые apt-пакеты (jq и др.)"
}
variable "install_nginx" {
type = bool
default = false
description = "Установить nginx"
}
variable "install_docker" {
type = bool
default = false
description = "Установить Docker CE + docker-compose-plugin"
}
# ---- Параметры ------------------------------------------------------------
variable "base_packages" {
type = list(string)
default = ["jq", "python3-pip", "htop", "unzip"]
description = "Список apt-пакетов для install-packages"
}
variable "install_run_id" {
type = number
default = 1
description = "Увеличь на 1 чтобы запустить все install-джобы заново"
}
# ---- Идентификаторы сервисов Nubes ----------------------------------------
# Берутся из Личного Кабинета → «Мои сервисы» → нужный сервис → параметры инстанса.
# Не изменяются после создания vApp.
variable "vdc_uid" {
type = string
description = "UUID услуги «Виртуальный датацентр (vDC)». Личный Кабинет → Мои сервисы → vDC → UUID."
}
variable "nsxt_uid" {
type = string
description = "UUID услуги «Сетевой шлюз периметра (Edge / NSX-T)». Личный Кабинет → Мои сервисы → Edge → UUID."
}
-37
View File
@@ -1,37 +0,0 @@
// 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-1"
vm_name = "web02" # Имя ВМ в Nubes vCD. Не изменяется после создания.
# Определяет имя NSX-T IP Set: {vapp_name}-{vm_name}
vapp_uid = nubes_vapp.vapp.id # ссылка на vApp. Не изменяется после создания.
image_vm = "Ubuntu_22-20G" # Не изменяется после создания.
# 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-адреса, статус и т.д."
}
-931
View File
@@ -1,931 +0,0 @@
#!/usr/bin/env bash
# 2026-03-30 — vm_stress_test.sh (v2 — READ-ONLY)
# Автономный READ-ONLY stress/chaos тест для examples/VM.
#
# ⛔⛔⛔ КРИТИЧЕСКОЕ ПРАВИЛО — ДЛЯ AI-АГЕНТОВ И ЛЮДЕЙ ⛔⛔⛔
#
# Этот скрипт НИКОГДА НЕ МОДИФИЦИРУЕТ terraform.tfvars и НИКАКИЕ ДРУГИЕ ФАЙЛЫ.
# Все переопределения переменных — ТОЛЬКО через terraform CLI опцию -var.
# Файл terraform.tfvars ЧИТАЕТСЯ, но НИКОГДА НЕ ПЕРЕЗАПИСЫВАЕТСЯ.
#
# ЗАПРЕЩЕНО:
# - Редактировать этот скрипт
# - Редактировать terraform.tfvars
# - Редактировать любые .tf файлы
# - Запускать terraform напрямую — только через этот скрипт
#
# ПРИЧИНА: предыдущая версия скрипта уничтожила terraform.tfvars
# через write_tfvars() — потерян JWT-токен api_token. Это НЕДОПУСТИМО.
#
# ФАЗЫ:
# 1 BASELINE — apply с текущим tfvars (packages + nginx + docker)
# 2 IDEMPOTENT — повторный plan → "No changes"
# 3 PARTIAL_DISABLE — apply с -var install_nginx=false -var install_docker=false
# 4 PARTIAL_ENABLE — apply с -var install_nginx=true -var install_docker=true (run_id+1)
# 5 REORDER_PACKAGES — apply с -var 'base_packages=["htop","jq"]' (run_id+1)
# 6 MANUAL_PURGE — удалить пакеты с VM по SSH → apply (run_id+1)
# 7 DESTROY — terraform destroy → VM уходит в suspend
# 8 RESURRECT — apply после destroy → VM просыпается
# 9 STRESS_CYCLES — N подряд destroy/apply циклов
# 10 FINAL_SANITY — проверить доступность VM и пакеты
#
# ЗАПУСК:
# cd ~/terra/sless/examples/VM
# bash vm_stress_test.sh 2>&1 | tee /tmp/vm_stress_$(date +%Y%m%d_%H%M).log
#
# ПАРАМЕТРЫ (env):
# STRESS_CYCLES=2 — количество destroy/apply циклов в фазе 9 (default: 2)
# SKIP_DESTROY=1 — пропустить фазы 7-9 (для быстрого прогона)
#
# АНАЛИЗ РЕЗУЛЬТАТОВ:
# grep -E '\[(PASS|FAIL|SKIP)\]' /tmp/vm_stress.log
# Итоговая сводка печатается в конце лога.
#
# ТРЕБОВАНИЯ: terraform, ssh, python3 — всё на VM naeel@5.172.178.213
set -uo pipefail
# ── CONFIG ────────────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
VM_KEY="$SCRIPT_DIR/vm_key"
STRESS_CYCLES="${STRESS_CYCLES:-2}"
SKIP_DESTROY="${SKIP_DESTROY:-0}"
# Читаем текущий run_id из terraform.tfvars (ТОЛЬКО ЧТЕНИЕ, не запись)
RUN_ID=$(grep 'install_run_id' terraform.tfvars | grep -oP '\d+' | head -1)
# ── СТАТИСТИКА ────────────────────────────────────────────────────────────────
PASS=0; FAIL=0; SKIP=0
PHASE_RESULTS=()
START_TIME=$SECONDS
# ── ЦВЕТА ─────────────────────────────────────────────────────────────────────
GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
# ── HELPERS ───────────────────────────────────────────────────────────────────
pass() { echo -e " ${GREEN}[PASS]${RESET} $1"; ((PASS++)); }
fail() { echo -e " ${RED}[FAIL]${RESET} $1"; ((FAIL++)); }
skip() { echo -e " ${YELLOW}[SKIP]${RESET} $1"; ((SKIP++)); }
info() { echo -e " ${CYAN}[INFO]${RESET} $1"; }
warn() { echo -e " ${YELLOW}[WARN]${RESET} $1"; }
phase_header() {
local num="$1" name="$2"
echo ""
echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
echo -e "${BOLD}${CYAN} ФАЗА $num: $name${RESET}"
echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
echo -e " ${CYAN}[TIME]${RESET} $(date '+%H:%M:%S')"
}
phase_result() {
local name="$1" result="$2"
PHASE_RESULTS+=("$name:$result")
echo -e " ${CYAN}[PHASE]${RESET} $name → ${result}"
}
# ── TERRAFORM HELPERS ─────────────────────────────────────────────────────────
# ⛔ НЕ ТРОГАЕМ terraform.tfvars. Переопределения — ТОЛЬКО через -var.
# terraform.tfvars подхватывается автоматически (лежит в рабочей директории).
# Доп. -var аргументы передаются во все tf_* функции и ПЕРЕОПРЕДЕЛЯЮТ tfvars.
# tf_apply: apply с retry при сетевых ошибках.
# Аргументы: любые доп. -var (опционально).
# Пример: tf_apply -var install_nginx=false -var install_docker=false
tf_apply() {
local attempt=1 max=3
while [[ $attempt -le $max ]]; do
info "terraform apply (попытка $attempt/$max)..."
if terraform apply -auto-approve -input=false -no-color "$@" 2>&1 | tee /tmp/vm_tf_apply.log; then
return 0
fi
if grep -Eiq 'TLS handshake timeout|unexpected EOF|i/o timeout|context deadline|Client\.Timeout' /tmp/vm_tf_apply.log && [[ $attempt -lt $max ]]; then
warn "сетевой сбой, retry через $((attempt * 5))s..."
sleep $((attempt * 5))
((attempt++))
continue
fi
return 1
done
return 1
}
# tf_destroy: destroy с retry.
# Аргументы: любые доп. -var (опционально).
tf_destroy() {
local attempt=1 max=3
while [[ $attempt -le $max ]]; do
info "terraform destroy (попытка $attempt/$max)..."
if terraform destroy -auto-approve -input=false -no-color "$@" 2>&1 | tee /tmp/vm_tf_destroy.log; then
return 0
fi
if grep -Eiq 'TLS handshake timeout|unexpected EOF|i/o timeout|context deadline|Client\.Timeout' /tmp/vm_tf_destroy.log && [[ $attempt -lt $max ]]; then
warn "сетевой сбой, retry через $((attempt * 5))s..."
sleep $((attempt * 5))
((attempt++))
continue
fi
return 1
done
return 1
}
# tf_plan_no_changes: проверить plan → "No changes".
# Аргументы: любые доп. -var (опционально).
tf_plan_no_changes() {
terraform plan -input=false -no-color "$@" 2>&1 | tee /tmp/vm_tf_plan.log
grep -q 'No changes' /tmp/vm_tf_plan.log
}
# tf_state_count: количество ресурсов в state.
tf_state_count() {
terraform state list 2>/dev/null | wc -l
}
# next_run_id: инкрементировать внутренний счётчик RUN_ID и вернуть новое значение.
# Используется для -var install_run_id=N чтобы форсировать пересоздание jobs.
next_run_id() {
RUN_ID=$((RUN_ID + 1))
echo "$RUN_ID"
}
# ── VM SSH HELPERS ────────────────────────────────────────────────────────────
# get_vm_ip: получить внешний IP VM из terraform output.
get_vm_ip() {
terraform output -json vm_state 2>/dev/null \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('externalConnect',''))" 2>/dev/null
}
# vm_ssh: выполнить команду на VM. Возвращает exit code команды.
vm_ssh() {
local ip="$1"; shift
ssh -i "$VM_KEY" -o StrictHostKeyChecking=no -o ConnectTimeout=15 \
-o ServerAliveInterval=5 -o ServerAliveCountMax=3 \
"ubuntu@$ip" "$@"
}
# vm_alive: проверить доступность VM по SSH.
vm_alive() {
local ip="$1"
vm_ssh "$ip" 'echo alive' &>/dev/null
}
# vm_wait_alive: ждать пока VM ответит по SSH (до timeout_sec).
vm_wait_alive() {
local ip="$1" timeout_sec="${2:-120}"
local deadline=$((SECONDS + timeout_sec))
while [[ $SECONDS -lt $deadline ]]; do
if vm_alive "$ip"; then return 0; fi
sleep 10
done
return 1
}
# vm_check_package: проверить что пакет установлен.
vm_check_package() {
local ip="$1" pkg="$2"
vm_ssh "$ip" "dpkg -l $pkg 2>/dev/null | grep -q '^ii'" 2>/dev/null
}
# vm_check_binary: проверить что бинарник доступен.
vm_check_binary() {
local ip="$1" bin="$2"
vm_ssh "$ip" "command -v $bin" &>/dev/null
}
# vm_wait_binary: ждать пока бинарник появится на VM (sless_job работает асинхронно).
# Нужен потому что sless_job запускает kubernetes Job, который сначала собирает образ,
# затем стартует pod, и только потом SSH-устанавливает пакеты на VM — это занимает 1-3 мин.
vm_wait_binary() {
local ip="$1" bin="$2" timeout_sec="${3:-180}"
local deadline=$((SECONDS + timeout_sec))
info "жду появления '$bin' на VM (до ${timeout_sec}s)..."
while [[ $SECONDS -lt $deadline ]]; do
if vm_check_binary "$ip" "$bin"; then return 0; fi
sleep 15
done
return 1
}
# vm_purge_all: удалить все установленные пакеты с VM.
vm_purge_all() {
local ip="$1"
info "удаляю пакеты с VM $ip..."
vm_ssh "$ip" 'sudo systemctl stop nginx docker 2>/dev/null; sudo apt-get purge -y jq python3-pip htop unzip nginx docker-ce docker-ce-cli containerd.io docker-compose-plugin 2>/dev/null; sudo apt-get autoremove -y 2>/dev/null; sudo rm -rf /var/lib/docker /var/lib/containerd; echo purge_done' 2>/dev/null
}
# ══════════════════════════════════════════════════════════════════════════════
# ФАЗА 1: BASELINE — apply с текущим tfvars (всё включено)
# Используем -var install_run_id=N чтобы гарантировать свежий запуск.
# terraform.tfvars НЕ ТРОГАЕМ — берём как есть.
# ══════════════════════════════════════════════════════════════════════════════
phase_1_baseline() {
phase_header 1 "BASELINE — apply с полным набором"
local rid
rid=$(next_run_id)
# Все флаги = true (как в tfvars), но run_id инкрементирован
if tf_apply \
-var "install_packages=true" \
-var "install_nginx=true" \
-var "install_docker=true" \
-var "install_run_id=$rid"; then
pass "1.1 terraform apply завершился успешно"
else
fail "1.1 terraform apply упал"
phase_result "BASELINE" "FAIL"
return 1
fi
# Проверить количество ресурсов
local count
count=$(tf_state_count)
if [[ $count -ge 5 ]]; then
pass "1.2 state содержит $count ресурсов (ожидали ≥5)"
else
fail "1.2 state содержит $count ресурсов (ожидали ≥5)"
fi
# Проверить outputs
local out
out=$(terraform output -json 2>/dev/null)
if echo "$out" | python3 -c "import sys,json; d=json.load(sys.stdin); assert 'ok' in d['install_packages_result']['value'] or 'already' in d['install_packages_result']['value']" 2>/dev/null; then
pass "1.3 install_packages_result содержит ok/already_installed"
else
fail "1.3 install_packages_result не содержит ok"
fi
if echo "$out" | python3 -c "import sys,json; d=json.load(sys.stdin); v=d['install_nginx_result']['value']; assert 'ok' in v or 'already' in v" 2>/dev/null; then
pass "1.4 install_nginx_result содержит ok/already"
else
fail "1.4 install_nginx_result не содержит ok"
fi
if echo "$out" | python3 -c "import sys,json; d=json.load(sys.stdin); v=d['install_docker_result']['value']; assert 'ok' in v or 'already' in v" 2>/dev/null; then
pass "1.5 install_docker_result содержит ok/already"
else
fail "1.5 install_docker_result не содержит ok"
fi
# Проверить VM по SSH
local ip
ip=$(get_vm_ip)
if [[ -n "$ip" ]] && vm_alive "$ip"; then
pass "1.6 VM $ip доступна по SSH"
else
fail "1.6 VM не доступна по SSH (ip=$ip)"
fi
phase_result "BASELINE" "PASS"
}
# ══════════════════════════════════════════════════════════════════════════════
# ФАЗА 2: IDEMPOTENT — повторный apply с теми же аргументами → 0 changed
# Используем apply (не plan) — это надёжнее: некоторые sless-провайдеры показывают
# ложный drift в plan, но apply при этом возвращает 0 changed. Apply — canonical way.
# ══════════════════════════════════════════════════════════════════════════════
phase_2_idempotent() {
phase_header 2 "IDEMPOTENT — повторный apply без изменений"
# Тот же run_id что применялся в фазе 1 → apply должен вернуть 0 changed
# sless_job — эфемерный ресурс, каждый apply пересоздаёт job-ресурсы (add+destroy).
# Идемпотентность = "0 changed" (ноль in-place изменений), а не "0 add/destroy".
# VM и vApp не должны изменяться никогда.
if tf_apply \
-var "install_packages=true" \
-var "install_nginx=true" \
-var "install_docker=true" \
-var "install_run_id=$RUN_ID"; then
if grep -q ', 0 changed,' /tmp/vm_tf_apply.log; then
pass "2.1 повторный apply → 0 changed (sless_job пересоздан — ожидаемо)"
grep -E 'Resources:' /tmp/vm_tf_apply.log | tail -1 | while read -r line; do
info " $line"
done
else
fail "2.1 повторный apply изменил persistent ресурсы (ожидали 0 changed)"
grep -E 'added|changed|destroyed' /tmp/vm_tf_apply.log | tail -3 | while read -r line; do
info " $line"
done
fi
else
fail "2.1 повторный apply упал"
fi
phase_result "IDEMPOTENT" "PASS"
}
# ══════════════════════════════════════════════════════════════════════════════
# ФАЗА 3: PARTIAL_DISABLE — выключить nginx + docker
# Используем -var install_nginx=false -var install_docker=false
# terraform.tfvars по-прежнему НЕ ТРОГАЕМ.
# ══════════════════════════════════════════════════════════════════════════════
phase_3_partial_disable() {
phase_header 3 "PARTIAL_DISABLE — выключить nginx + docker"
if tf_apply \
-var "install_packages=true" \
-var "install_nginx=false" \
-var "install_docker=false" \
-var "install_run_id=$RUN_ID"; then
pass "3.1 apply с partial disable завершился"
else
fail "3.1 apply с partial disable упал"
phase_result "PARTIAL_DISABLE" "FAIL"
return 1
fi
# Проверить что nginx и docker job пропали из state
local state_list
state_list=$(terraform state list 2>/dev/null)
if echo "$state_list" | grep -q 'install_nginx'; then
fail "3.2 install_nginx всё ещё в state"
else
pass "3.2 install_nginx убран из state"
fi
if echo "$state_list" | grep -q 'install_docker'; then
fail "3.3 install_docker всё ещё в state"
else
pass "3.3 install_docker убран из state"
fi
if echo "$state_list" | grep -q 'install_packages'; then
pass "3.4 install_packages остался в state"
else
fail "3.4 install_packages пропал из state"
fi
# Проверить outputs
local nginx_out docker_out
nginx_out=$(terraform output -raw install_nginx_result 2>/dev/null)
docker_out=$(terraform output -raw install_docker_result 2>/dev/null)
if [[ "$nginx_out" == "skipped" ]]; then
pass "3.5 install_nginx_result = skipped"
else
fail "3.5 install_nginx_result = '$nginx_out' (ожидали skipped)"
fi
if [[ "$docker_out" == "skipped" ]]; then
pass "3.6 install_docker_result = skipped"
else
fail "3.6 install_docker_result = '$docker_out' (ожидали skipped)"
fi
phase_result "PARTIAL_DISABLE" "PASS"
}
# ══════════════════════════════════════════════════════════════════════════════
# ФАЗА 4: PARTIAL_ENABLE — включить обратно всё
# Инкрементируем run_id чтобы jobs пересоздались.
# ══════════════════════════════════════════════════════════════════════════════
phase_4_partial_enable() {
phase_header 4 "PARTIAL_ENABLE — включить обратно всё"
local rid
rid=$(next_run_id)
if tf_apply \
-var "install_packages=true" \
-var "install_nginx=true" \
-var "install_docker=true" \
-var "install_run_id=$rid"; then
pass "4.1 apply с полным набором завершился"
else
fail "4.1 apply с полным набором упал"
phase_result "PARTIAL_ENABLE" "FAIL"
return 1
fi
local count
count=$(tf_state_count)
if [[ $count -ge 5 ]]; then
pass "4.2 state содержит $count ресурсов (ожидали ≥5)"
else
fail "4.2 state содержит $count ресурсов (ожидали ≥5)"
fi
phase_result "PARTIAL_ENABLE" "PASS"
}
# ══════════════════════════════════════════════════════════════════════════════
# ФАЗА 5: REORDER_PACKAGES — изменить порядок и состав base_packages
# Через -var 'base_packages=["htop","jq"]' — terraform.tfvars не трогаем.
# ══════════════════════════════════════════════════════════════════════════════
phase_5_reorder() {
phase_header 5 "REORDER_PACKAGES — изменить порядок и состав пакетов"
local rid
rid=$(next_run_id)
# Сокращённый набор пакетов через -var
if tf_apply \
-var "install_packages=true" \
-var "install_nginx=true" \
-var "install_docker=true" \
-var "install_run_id=$rid" \
-var 'base_packages=["htop","jq"]'; then
pass "5.1 apply с изменённым набором пакетов завершился"
else
fail "5.1 apply с изменённым набором пакетов упал"
phase_result "REORDER_PACKAGES" "FAIL"
return 1
fi
# Проверить output
local pkg_out
pkg_out=$(terraform output -raw install_packages_result 2>/dev/null)
if echo "$pkg_out" | grep -qE '"status"|ok|already'; then
pass "5.2 install_packages вернул ожидаемый результат"
else
fail "5.2 install_packages output неожиданный: $pkg_out"
fi
# Вернуть полный набор пакетов
rid=$(next_run_id)
tf_apply \
-var "install_packages=true" \
-var "install_nginx=true" \
-var "install_docker=true" \
-var "install_run_id=$rid" \
|| warn "5.3 восстановление полного набора не удалось"
phase_result "REORDER_PACKAGES" "PASS"
}
# ══════════════════════════════════════════════════════════════════════════════
# ФАЗА 6: MANUAL_PURGE — удалить пакеты с VM вручную, заново установить
# ══════════════════════════════════════════════════════════════════════════════
phase_6_manual_purge() {
phase_header 6 "MANUAL_PURGE — удалить пакеты с VM, заново установить"
local ip
ip=$(get_vm_ip)
if [[ -z "$ip" ]]; then
fail "6.0 не удалось получить IP VM"
phase_result "MANUAL_PURGE" "FAIL"
return 1
fi
# Удалить всё с VM
vm_purge_all "$ip"
# Проверить что пакеты действительно удалены
if vm_check_binary "$ip" "docker"; then
fail "6.1 docker всё ещё на VM после purge"
else
pass "6.1 docker удалён с VM"
fi
if vm_check_binary "$ip" "nginx"; then
fail "6.2 nginx всё ещё на VM после purge"
else
pass "6.2 nginx удалён с VM"
fi
if vm_check_binary "$ip" "jq"; then
fail "6.3 jq всё ещё на VM после purge"
else
pass "6.3 jq удалён с VM"
fi
# Пересоздать jobs (bump run_id)
local rid
rid=$(next_run_id)
info "запускаю переустановку через sless_job..."
if tf_apply \
-var "install_packages=true" \
-var "install_nginx=true" \
-var "install_docker=true" \
-var "install_run_id=$rid"; then
pass "6.4 apply после purge завершился"
else
fail "6.4 apply после purge упал"
phase_result "MANUAL_PURGE" "FAIL"
return 1
fi
# Ждать пока sless_job отработает: docker самый долгий (k8s Job + образ + apt-install ~200MB).
# docker-ce требует до 5 минут на первой установке — ставим 360s.
if vm_wait_binary "$ip" "docker" 360; then
pass "6.5 docker установлен заново"
else
fail "6.5 docker НЕ установлен после re-apply (таймаут 360s)"
fi
if vm_wait_binary "$ip" "nginx" 240; then
pass "6.6 nginx установлен заново"
else
fail "6.6 nginx НЕ установлен после re-apply (таймаут 240s)"
fi
if vm_check_binary "$ip" "jq"; then
pass "6.7 jq установлен заново"
else
fail "6.7 jq НЕ установлен после re-apply"
fi
phase_result "MANUAL_PURGE" "PASS"
}
# ══════════════════════════════════════════════════════════════════════════════
# ФАЗА 7: DESTROY — terraform destroy, VM уходит в suspend
# ══════════════════════════════════════════════════════════════════════════════
phase_7_destroy() {
phase_header 7 "DESTROY — terraform destroy → VM в suspend"
local ip
ip=$(get_vm_ip)
if tf_destroy; then
pass "7.1 terraform destroy завершился"
else
fail "7.1 terraform destroy упал"
phase_result "DESTROY" "FAIL"
return 1
fi
# State должен быть пуст
local count
count=$(tf_state_count)
if [[ $count -eq 0 ]]; then
pass "7.2 state пуст ($count ресурсов)"
else
fail "7.2 state не пуст ($count ресурсов)"
fi
# VM не должна отвечать по SSH
if [[ -n "$ip" ]]; then
info "проверяю что VM $ip недоступна..."
if vm_alive "$ip"; then
fail "7.3 VM $ip всё ещё отвечает по SSH после destroy"
else
pass "7.3 VM $ip не отвечает по SSH (suspend подтверждён)"
fi
else
skip "7.3 IP VM неизвестен, пропускаю SSH-проверку"
fi
phase_result "DESTROY" "PASS"
}
# ══════════════════════════════════════════════════════════════════════════════
# ФАЗА 8: RESURRECT — apply после destroy, VM просыпается
# Используем текущий run_id — terraform.tfvars НЕ ТРОГАЕМ.
# ══════════════════════════════════════════════════════════════════════════════
phase_8_resurrect() {
phase_header 8 "RESURRECT — apply после destroy"
local rid
rid=$(next_run_id)
if tf_apply \
-var "install_packages=true" \
-var "install_nginx=true" \
-var "install_docker=true" \
-var "install_run_id=$rid"; then
pass "8.1 apply после destroy завершился"
else
fail "8.1 apply после destroy упал"
phase_result "RESURRECT" "FAIL"
return 1
fi
local count
count=$(tf_state_count)
if [[ $count -ge 5 ]]; then
pass "8.2 state содержит $count ресурсов"
else
fail "8.2 state содержит $count ресурсов (ожидали ≥5)"
fi
# Проверить VM
local ip
ip=$(get_vm_ip)
if [[ -n "$ip" ]] && vm_alive "$ip"; then
pass "8.3 VM $ip доступна по SSH после resurrect"
else
# VM может ещё просыпаться — ждём
info "VM не отвечает, жду до 120s..."
if vm_wait_alive "$ip" 120; then
pass "8.3 VM $ip доступна по SSH после ожидания"
else
fail "8.3 VM $ip не доступна по SSH через 120s"
fi
fi
# Проверить пакеты
if [[ -n "$ip" ]] && vm_alive "$ip"; then
if vm_check_binary "$ip" "jq"; then
pass "8.4 jq установлен после resurrect"
else
fail "8.4 jq НЕ установлен после resurrect"
fi
if vm_check_binary "$ip" "nginx"; then
pass "8.5 nginx установлен после resurrect"
else
fail "8.5 nginx НЕ установлен после resurrect"
fi
if vm_check_binary "$ip" "docker"; then
pass "8.6 docker установлен после resurrect"
else
fail "8.6 docker НЕ установлен после resurrect"
fi
fi
phase_result "RESURRECT" "PASS"
}
# ══════════════════════════════════════════════════════════════════════════════
# ФАЗА 9: STRESS_CYCLES — N destroy/apply циклов подряд
# ══════════════════════════════════════════════════════════════════════════════
phase_9_stress() {
phase_header 9 "STRESS_CYCLES — $STRESS_CYCLES циклов destroy/apply"
local i rid
for i in $(seq 1 "$STRESS_CYCLES"); do
info "── цикл $i/$STRESS_CYCLES ──"
info "[$i] destroy..."
if tf_destroy; then
pass "9.${i}a destroy цикл $i"
else
fail "9.${i}a destroy цикл $i упал"
# Попробовать восстановиться
rid=$(next_run_id)
tf_apply \
-var "install_packages=true" \
-var "install_nginx=true" \
-var "install_docker=true" \
-var "install_run_id=$rid" || true
continue
fi
rid=$(next_run_id)
info "[$i] apply (run_id=$rid)..."
if tf_apply \
-var "install_packages=true" \
-var "install_nginx=true" \
-var "install_docker=true" \
-var "install_run_id=$rid"; then
pass "9.${i}b apply цикл $i"
else
fail "9.${i}b apply цикл $i упал"
continue
fi
done
phase_result "STRESS_CYCLES" "PASS"
}
# ══════════════════════════════════════════════════════════════════════════════
# ФАЗА 10: FINAL_SANITY — финальная проверка состояния
# ══════════════════════════════════════════════════════════════════════════════
phase_10_final() {
phase_header 10 "FINAL_SANITY — финальная проверка"
# Убедиться что state на месте
local count
count=$(tf_state_count)
if [[ $count -ge 5 ]]; then
pass "10.1 state содержит $count ресурсов"
else
fail "10.1 state содержит $count ресурсов (ожидали ≥5)"
# Попробовать восстановить (через -var, БЕЗ изменения файлов)
local rid
rid=$(next_run_id)
info "пытаюсь восстановить baseline..."
tf_apply \
-var "install_packages=true" \
-var "install_nginx=true" \
-var "install_docker=true" \
-var "install_run_id=$rid" || warn "восстановление не удалось"
fi
# Idempotency финально: повторный apply → 0 changed.
# sless_job пересоздаются (add+destroy) — это нормально для job-ресурса.
# Проверяем только "0 changed" — VM и vApp не должны изменяться.
if tf_apply \
-var "install_packages=true" \
-var "install_nginx=true" \
-var "install_docker=true" \
-var "install_run_id=$RUN_ID"; then
if grep -q ', 0 changed,' /tmp/vm_tf_apply.log; then
pass "10.2 финальный apply → 0 changed (sless_job пересоздан — ожидаемо)"
grep -E 'Resources:' /tmp/vm_tf_apply.log | tail -1 | while read -r line; do
info " $line"
done
else
fail "10.2 финальный apply изменил persistent ресурсы (ожидали 0 changed)"
grep -E 'added|changed|destroyed' /tmp/vm_tf_apply.log | tail -3 | while read -r line; do
info " $line"
done
fi
else
fail "10.2 финальный apply упал"
fi
# VM доступна
local ip
ip=$(get_vm_ip)
if [[ -n "$ip" ]] && vm_alive "$ip"; then
pass "10.3 VM $ip доступна по SSH"
# Полная проверка пакетов
for pkg in jq htop unzip; do
if vm_check_package "$ip" "$pkg"; then
pass "10.4 пакет $pkg установлен"
else
fail "10.4 пакет $pkg НЕ установлен"
fi
done
if vm_check_binary "$ip" "nginx"; then
pass "10.5 nginx работает"
else
fail "10.5 nginx НЕ найден"
fi
if vm_check_binary "$ip" "docker"; then
pass "10.6 docker работает"
else
fail "10.6 docker НЕ найден"
fi
# nginx service активен (не просто бинарник, а именно демон)
if vm_ssh "$ip" "systemctl is-active nginx 2>/dev/null" 2>/dev/null | grep -q '^active$'; then
pass "10.7 nginx service активен (systemctl)"
else
fail "10.7 nginx service не активен"
fi
# docker daemon активен
if vm_ssh "$ip" "systemctl is-active docker 2>/dev/null" 2>/dev/null | grep -q '^active$'; then
pass "10.8 docker daemon активен (systemctl)"
else
fail "10.8 docker daemon не активен"
fi
# HTTP probe: nginx отвечает на localhost:80
local http_code
http_code=$(vm_ssh "$ip" "curl -sS -o /dev/null -w '%{http_code}' http://localhost 2>/dev/null" 2>/dev/null)
if [[ "$http_code" == "200" ]]; then
pass "10.9 nginx отвечает HTTP 200"
else
fail "10.9 nginx не отвечает HTTP 200 (code='$http_code')"
fi
# python3 доступен
local py_ver
py_ver=$(vm_ssh "$ip" "python3 --version 2>&1" 2>/dev/null)
if echo "$py_ver" | grep -q 'Python 3'; then
pass "10.10 python3 доступен ($py_ver)"
else
fail "10.10 python3 недоступен"
fi
# docker smoke: запустить контейнер и проверить вывод
if vm_ssh "$ip" "docker run --rm hello-world 2>&1 | grep -q 'Hello from Docker'" 2>/dev/null; then
pass "10.11 docker run hello-world → успешно"
else
fail "10.11 docker run hello-world → не прошёл"
fi
# disk space: убедиться что на VM есть место (>500MB free)
local free_mb
free_mb=$(vm_ssh "$ip" "df -m / 2>/dev/null | awk 'NR==2{print \$4}'" 2>/dev/null)
if [[ -n "$free_mb" && "$free_mb" -gt 500 ]]; then
pass "10.12 свободное место на / = ${free_mb}MB (>500MB)"
else
fail "10.12 мало места на / = ${free_mb}MB (ожидали >500MB)"
fi
else
fail "10.3 VM не доступна по SSH"
fi
phase_result "FINAL_SANITY" "PASS"
}
# ══════════════════════════════════════════════════════════════════════════════
# MAIN
# ══════════════════════════════════════════════════════════════════════════════
echo -e "${BOLD}${CYAN}"
echo "╔═══════════════════════════════════════════════════════════════╗"
echo "║ VM STRESS TEST v2 (READ-ONLY) — examples/VM ║"
echo "║ $(date '+%Y-%m-%d %H:%M:%S') ║"
echo "║ Начальный run_id: $RUN_ID ║"
echo "║ Циклов stress: $STRESS_CYCLES ║"
echo "║ terraform.tfvars НЕ МОДИФИЦИРУЕТСЯ ║"
echo "╚═══════════════════════════════════════════════════════════════╝"
echo -e "${RESET}"
# Проверить что мы в правильной директории
if [[ ! -f "terraform.tfvars" ]]; then
echo -e "${RED}ОШИБКА: terraform.tfvars не найден. Запускайте из examples/VM/${RESET}"
exit 1
fi
if [[ ! -f "$VM_KEY" ]]; then
echo -e "${RED}ОШИБКА: $VM_KEY не найден${RESET}"
exit 1
fi
# ⛔ ПРОВЕРКА ЦЕЛОСТНОСТИ: terraform.tfvars НЕ ДОЛЖЕН БЫТЬ МОДИФИЦИРОВАН
# Сохраняем md5 до запуска и проверяем после каждой фазы
TFVARS_MD5=$(md5sum terraform.tfvars | awk '{print $1}')
info "md5 terraform.tfvars = $TFVARS_MD5 (будет проверяться после каждой фазы)"
check_tfvars_integrity() {
local current_md5
current_md5=$(md5sum terraform.tfvars | awk '{print $1}')
if [[ "$current_md5" != "$TFVARS_MD5" ]]; then
echo -e "${RED}⛔⛔⛔ КРИТИЧЕСКАЯ ОШИБКА: terraform.tfvars был изменён! ⛔⛔⛔${RESET}"
echo -e "${RED}Ожидали md5: $TFVARS_MD5${RESET}"
echo -e "${RED}Текущий md5: $current_md5${RESET}"
echo -e "${RED}АВАРИЙНАЯ ОСТАНОВКА ТЕСТА${RESET}"
exit 99
fi
}
# Запуск фаз с проверкой целостности после каждой
phase_1_baseline; check_tfvars_integrity
phase_2_idempotent; check_tfvars_integrity
phase_3_partial_disable; check_tfvars_integrity
phase_4_partial_enable; check_tfvars_integrity
phase_5_reorder; check_tfvars_integrity
phase_6_manual_purge; check_tfvars_integrity
if [[ "$SKIP_DESTROY" == "1" ]]; then
skip "фазы 7-9 пропущены (SKIP_DESTROY=1)"
PHASE_RESULTS+=("DESTROY:SKIP" "RESURRECT:SKIP" "STRESS_CYCLES:SKIP")
else
phase_7_destroy; check_tfvars_integrity
phase_8_resurrect; check_tfvars_integrity
phase_9_stress; check_tfvars_integrity
fi
phase_10_final; check_tfvars_integrity
# ── ИТОГОВАЯ СВОДКА ──────────────────────────────────────────────────────────
ELAPSED=$((SECONDS - START_TIME))
ELAPSED_MIN=$((ELAPSED / 60))
ELAPSED_SEC=$((ELAPSED % 60))
echo ""
echo -e "${BOLD}${CYAN}╔═══════════════════════════════════════════════════════════════╗${RESET}"
echo -e "${BOLD}${CYAN}║ ИТОГОВАЯ СВОДКА ║${RESET}"
echo -e "${BOLD}${CYAN}╠═══════════════════════════════════════════════════════════════╣${RESET}"
echo -e "${BOLD} Время: ${ELAPSED_MIN}m ${ELAPSED_SEC}s${RESET}"
echo -e "${BOLD} ${GREEN}PASS: $PASS${RESET} ${RED}FAIL: $FAIL${RESET} ${YELLOW}SKIP: $SKIP${RESET}"
echo -e "${BOLD} Финальный run_id: $RUN_ID${RESET}"
echo ""
echo -e "${BOLD} Фазы:${RESET}"
for pr in "${PHASE_RESULTS[@]}"; do
name="${pr%%:*}"
result="${pr##*:}"
case "$result" in
PASS) echo -e " ${GREEN}✓${RESET} $name" ;;
FAIL) echo -e " ${RED}✗${RESET} $name" ;;
SKIP) echo -e " ${YELLOW}○${RESET} $name" ;;
esac
done
echo -e "${BOLD}${CYAN}╚═══════════════════════════════════════════════════════════════╝${RESET}"
# Финальная проверка целостности tfvars
check_tfvars_integrity
info "terraform.tfvars НЕ БЫЛ ИЗМЕНЁН (md5 совпадает)"
# Exit code: 0 если все PASS, 1 если есть FAIL
if [[ $FAIL -gt 0 ]]; then
echo -e "\n${RED}РЕЗУЛЬТАТ: FAIL ($FAIL ошибок)${RESET}"
exit 1
else
echo -e "\n${GREEN}РЕЗУЛЬТАТ: ALL PASS${RESET}"
exit 0
fi
+1 -17
View File
@@ -1,5 +1,4 @@
// Изменено: 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)
@@ -66,7 +65,7 @@ const server = http.createServer(async (req, res) => {
try {
const result = await userHandle(event);
sendResult(res, 200, result);
sendJSON(res, 200, result);
} catch (err) {
console.error('Handler error:', err);
sendJSON(res, 500, { error: err.message });
@@ -74,21 +73,6 @@ 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, {