Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3404af578b | ||
|
|
8051870208 | ||
|
|
cad89fdbb6 | ||
|
|
6e73729c46 | ||
|
|
470039f6d6 | ||
|
|
f68b601484 | ||
|
|
442ba8bc2f | ||
|
|
b7fa8acf76 | ||
|
|
7a168185ea | ||
|
|
cb77a7f68e | ||
|
|
2e7cd7f4f7 | ||
|
|
c033adec11 | ||
|
|
9edd43edc5 |
@@ -106,11 +106,41 @@ func (r *FunctionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
|
||||
|
||||
const finalizerName = "sless.kube5s.ru/finalizer"
|
||||
|
||||
// startBuild запускает kaniko Job и помечает функцию как Building.
|
||||
// startBuild проверяет наличие образа в registry и либо пропускает сборку,
|
||||
// либо запускает kaniko Job. Идемпотентность: если код не менялся (тег = hash s3Key),
|
||||
// образ уже в registry → deploy без пересборки.
|
||||
// Критически важно: СНАЧАЛА сохраняем last-built-s3key аннотацию, ПОТОМ status.
|
||||
// Это предотвращает повторный запуск сборки при параллельных reconcile —
|
||||
// следующий reconcile увидит last-built-s3key == spec.S3Key и не войдёт в startBuild.
|
||||
func (r *FunctionReconciler) startBuild(ctx context.Context, fn *slessv1alpha1.Function) (ctrl.Result, error) {
|
||||
imageRef := r.Builder.ImageRef(fn.Namespace, fn.Name, fn.Spec.S3Key)
|
||||
|
||||
// Проверяем: образ с этим тегом уже существует в registry?
|
||||
// Если да — пропускаем kaniko, сразу переходим в Ready.
|
||||
// Если registry недоступен — requeue, не запускаем сборку (kaniko тоже упадёт).
|
||||
exists, err := r.Builder.ImageExists(ctx, imageRef)
|
||||
if err != nil {
|
||||
return ctrl.Result{RequeueAfter: 10 * time.Second}, fmt.Errorf("check image exists: %w", err)
|
||||
}
|
||||
if exists {
|
||||
logger := log.FromContext(ctx)
|
||||
logger.Info("image already exists in registry, skipping build", "imageRef", imageRef)
|
||||
|
||||
if fn.Annotations == nil {
|
||||
fn.Annotations = map[string]string{}
|
||||
}
|
||||
fn.Annotations["sless.kube5s.ru/last-built-s3key"] = fn.Spec.S3Key
|
||||
if err := r.Update(ctx, fn); err != nil {
|
||||
return ctrl.Result{}, fmt.Errorf("update annotations (cache hit): %w", err)
|
||||
}
|
||||
|
||||
fn.Status.Phase = slessv1alpha1.FunctionPhaseReady
|
||||
fn.Status.ImageRef = imageRef
|
||||
fn.Status.Message = "Image restored from registry cache"
|
||||
if err := r.Status().Update(ctx, fn); err != nil {
|
||||
return ctrl.Result{}, fmt.Errorf("update status (cache hit): %w", err)
|
||||
}
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
jobName, err := r.Builder.Build(ctx, fn.Namespace, fn.Name, fn.Spec.S3Key)
|
||||
if err != nil {
|
||||
return r.setFailed(ctx, fn, fmt.Sprintf("failed to start build: %v", err))
|
||||
|
||||
@@ -109,7 +109,8 @@ func (r *FunctionJobReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
||||
return r.createRunJob(ctx, fj, deployNS, jobName)
|
||||
}
|
||||
|
||||
// startJobBuild запускает kaniko сборку образа и переводит FunctionJob в фазу Building.
|
||||
// startJobBuild проверяет наличие образа в registry и либо пропускает сборку,
|
||||
// либо запускает kaniko Job. Идемпотентность по hash s3Key аналогична service/function.
|
||||
func (r *FunctionJobReconciler) startJobBuild(ctx context.Context, fj *slessv1alpha1.FunctionJob) (ctrl.Result, error) {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
@@ -120,6 +121,33 @@ func (r *FunctionJobReconciler) startJobBuild(ctx context.Context, fj *slessv1al
|
||||
return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
|
||||
}
|
||||
|
||||
imageRef := r.Builder.ImageRef(r.OperatorNamespace, fj.Name, fj.Spec.S3Key)
|
||||
|
||||
// Проверяем: образ с этим тегом уже существует в registry?
|
||||
// Если registry недоступен — requeue, не запускаем сборку.
|
||||
exists, err := r.Builder.ImageExists(ctx, imageRef)
|
||||
if err != nil {
|
||||
return ctrl.Result{RequeueAfter: 10 * time.Second}, fmt.Errorf("check image exists: %w", err)
|
||||
}
|
||||
if exists {
|
||||
logger.Info("image already exists in registry, skipping build", "imageRef", imageRef)
|
||||
|
||||
if fj.Annotations == nil {
|
||||
fj.Annotations = map[string]string{}
|
||||
}
|
||||
if err := r.Update(ctx, fj); err != nil {
|
||||
return ctrl.Result{}, fmt.Errorf("update annotations (cache hit): %w", err)
|
||||
}
|
||||
|
||||
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseBuilding // перейдёт в run сразу
|
||||
fj.Status.ImageRef = imageRef
|
||||
fj.Status.Message = "Image restored from registry cache"
|
||||
if err := r.Status().Update(ctx, fj); err != nil {
|
||||
return ctrl.Result{}, fmt.Errorf("update status (cache hit): %w", err)
|
||||
}
|
||||
return ctrl.Result{Requeue: true}, nil
|
||||
}
|
||||
|
||||
buildJobName, err := r.Builder.Build(ctx, r.OperatorNamespace, fj.Name, fj.Spec.S3Key)
|
||||
if err != nil {
|
||||
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseFailed
|
||||
|
||||
@@ -101,8 +101,40 @@ func (r *ServiceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
// startServiceBuild запускает kaniko Job и помечает сервис как Building.
|
||||
// startServiceBuild проверяет наличие образа в registry и либо пропускает сборку,
|
||||
// либо запускает kaniko Job. Идемпотентность: если код не менялся (тег = hash s3Key),
|
||||
// образ уже в registry → deploy без пересборки.
|
||||
func (r *ServiceReconciler) startServiceBuild(ctx context.Context, svc *slessv1alpha1.Service) (ctrl.Result, error) {
|
||||
imageRef := r.Builder.ImageRef(svc.Namespace, svc.Name, svc.Spec.S3Key)
|
||||
|
||||
// Проверяем: образ с этим тегом уже существует в registry?
|
||||
// Если да — пропускаем kaniko, сразу переходим в Ready с известным imageRef.
|
||||
// Если registry недоступен — requeue, не запускаем сборку (kaniko тоже упадёт).
|
||||
exists, err := r.Builder.ImageExists(ctx, imageRef)
|
||||
if err != nil {
|
||||
return ctrl.Result{RequeueAfter: 10 * time.Second}, fmt.Errorf("check image exists: %w", err)
|
||||
}
|
||||
if exists {
|
||||
logger := log.FromContext(ctx)
|
||||
logger.Info("image already exists in registry, skipping build", "imageRef", imageRef)
|
||||
|
||||
if svc.Annotations == nil {
|
||||
svc.Annotations = map[string]string{}
|
||||
}
|
||||
svc.Annotations["sless.kube5s.ru/last-built-s3key"] = svc.Spec.S3Key
|
||||
if err := r.Update(ctx, svc); err != nil {
|
||||
return ctrl.Result{}, fmt.Errorf("update service annotations (cache hit): %w", err)
|
||||
}
|
||||
|
||||
svc.Status.Phase = slessv1alpha1.ServicePhaseReady
|
||||
svc.Status.ImageRef = imageRef
|
||||
svc.Status.Message = "Image restored from registry cache"
|
||||
if err := r.Status().Update(ctx, svc); err != nil {
|
||||
return ctrl.Result{}, fmt.Errorf("update service status (cache hit): %w", err)
|
||||
}
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
jobName, err := r.Builder.Build(ctx, svc.Namespace, svc.Name, svc.Spec.S3Key)
|
||||
if err != nil {
|
||||
return r.setServiceFailed(ctx, svc, fmt.Sprintf("failed to start build: %v", err))
|
||||
|
||||
@@ -4,6 +4,68 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-22 — БАГ: CreateService/CreateFunction возвращает 409 при `terraform apply -replace` (ИСПРАВЛЕН)
|
||||
|
||||
### Симптом
|
||||
|
||||
```
|
||||
terraform apply -replace=sless_service.pg_info
|
||||
sless_service.pg_info: Destroying... [name=pg-info]
|
||||
sless_service.pg_info: Destruction complete
|
||||
sless_service.pg_info: Creating...
|
||||
Error: create service: status 409: {"error":"service already exists"}
|
||||
```
|
||||
|
||||
Все 22+ сервиса из `-replace` падают с 409 при пересоздании.
|
||||
|
||||
### Точная причина
|
||||
|
||||
**Цепочка событий** (воспроизводится только при наличии finalizer):
|
||||
|
||||
1. `terraform` вызывает `DELETE /services/pg-info`
|
||||
2. API делает `h.K8s.Delete(svc)` → k8s **НЕ удаляет объект** немедленно.
|
||||
Вместо этого — выставляет `DeletionTimestamp` на объекте и ждёт.
|
||||
3. `service_controller.go` (асинхронно!) обрабатывает удаление:
|
||||
- сносит Deployment, k8s Service, Ingress
|
||||
- затем вызывает `svc.Finalizers = removeString(svc.Finalizers, serviceFinalizerName)`
|
||||
- только после этого k8s реально удаляет CRD объект из etcd
|
||||
- **латентность: 1–5 секунд**
|
||||
4. `terraform` **немедленно** вызывает `POST /services/pg-info`
|
||||
5. API делает `h.K8s.Create(svc)` → etcd возвращает `IsAlreadyExists`
|
||||
6. Старый код проверял: `phase == Failed`? → нет, было `Ready` → `shouldRecreate=false` → **409**
|
||||
|
||||
**Ключевое: объект существует с `DeletionTimestamp != zero`, то есть он уже "мёртвый", но finalizer ещё не снят. Старый код этого не проверял.**
|
||||
|
||||
### Файл с багом
|
||||
|
||||
`internal/api/handler/services.go` → `CreateService()` — строка `shouldRecreate`
|
||||
`internal/api/handler/functions.go` → `CreateFunction()` — аналогичная логика
|
||||
|
||||
### Исправление
|
||||
|
||||
В обоих файлах добавлена проверка `!existing.DeletionTimestamp.IsZero()` **до** проверки `shouldRecreate`:
|
||||
|
||||
```go
|
||||
if getErr == nil && !existing.DeletionTimestamp.IsZero() {
|
||||
// Объект удаляется: polling каждую секунду до 30 сек
|
||||
for i := 0; i < 30; i++ {
|
||||
time.Sleep(1 * time.Second)
|
||||
if errors.IsNotFound(h.K8s.Get(...)) {
|
||||
// исчez — создаём
|
||||
}
|
||||
}
|
||||
// таймаут — возвращаем 409 с "try again later"
|
||||
}
|
||||
```
|
||||
|
||||
### Почему именно polling, а не Watch
|
||||
|
||||
`http.ResponseWriter` не поддерживает long-poll без дополнительного механизма.
|
||||
Watch на объект внутри HTTP handler — антипаттерн (использует goroutine leak при отмене).
|
||||
30 × 1s — достаточно для любого разумного кластера; terraform имеет свой retry.
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-22 — ПОВЕДЕНИЕ: оператор выставляет Ready до готовности pod (known limitation)
|
||||
|
||||
### Симптом
|
||||
|
||||
+58
-1
@@ -1,6 +1,63 @@
|
||||
# Прогресс разработки
|
||||
|
||||
Последнее обновление: 2026-03-22
|
||||
Последнее обновление: 2026-03-23
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-23 — Сессия 11: Баги cache-тест, fix оператора v0.1.62, fix провайдера
|
||||
|
||||
### Что сделано
|
||||
|
||||
**Bug 1: Go 1.23 go.work + replace конфликт (FIXED → v0.1.61)**
|
||||
- `internal/builder/context.go`: убран `replace sless/fn/handler => ./handler` из go.work шаблона
|
||||
- Добавлен `sed` переименования модуля вместо replace
|
||||
- Ошибка была: `go: workspace module sless/fn/handler is replaced at all versions in the go.work file`
|
||||
|
||||
**Bug 2: controller-runtime cache lag → 404 на upload (FIXED → v0.1.62)**
|
||||
- `internal/api/handler/services.go`: retry loop 5×200ms в `UploadServiceCode`
|
||||
- `internal/api/handler/jobs.go`: retry loop 5×200ms в `UploadJobCode`
|
||||
- Причина: сразу после POST /services (201) informer cache ещё не синхронизирован → IsNotFound
|
||||
|
||||
**Bug 3: Провайдер — 409 при повторном apply после сбоя upload (FIXED)**
|
||||
- `terraform/provider/internal/resources/service_resource.go`: в `Create()` при ошибке upload — rollback `DeleteService()`
|
||||
- `terraform/provider/internal/resources/job_resource.go`: аналогично `DeleteJob()`
|
||||
- Причина: upload в S3 падал (сетевой сбой), terraform не записывал state, CR оставался в k8s → следующий apply получал 409
|
||||
|
||||
**test_cache_matrix.sh v4**
|
||||
- Убраны все `-target` из скрипта — terraform не должен касаться postgres при частичных операциях
|
||||
- `destroy_sless_only()`: переименует `chaos_marathon.tf`, `functions.tf`, `stress.tf` → `.bak`, делает apply (terraform сам удаляет), возвращает файлы
|
||||
- Phase 3a: закомментирует блоки ресурсов через Python вместо `-target`
|
||||
|
||||
**Operator v0.1.62** собран и задеплоен (`naeel/sless-operator:v0.1.62`)
|
||||
|
||||
### Статус
|
||||
✅ v0.1.62 Running
|
||||
✅ Провайдер пересобран на VM (`/tmp/sless-provider-dev/`)
|
||||
⏳ test_cache_matrix.sh v4 — Phase 1 в процессе (pg-stats удалили вручную, apply идёт)
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-23 — Сессия 10: ImageExists + деплой v0.1.58
|
||||
|
||||
### Что сделано
|
||||
|
||||
- **ImageExists** — новый метод `Builder.ImageExists(ctx, imageRef) bool` в `internal/builder/builder.go`
|
||||
- Docker Registry v2 API: anonymous bearer token → HEAD `/v2/{repo}/manifests/{tag}`
|
||||
- timeout 5s, fallback false при любой ошибке (безопасный fallback → kaniko)
|
||||
- Приватные registry (Harbor и др.) → 401 → false → строим заново
|
||||
- **service_controller.go** `startServiceBuild()`: кеш-проверка перед Build(); cache hit → `Status.Phase=Ready` + `Status.ImageRef` без kaniko
|
||||
- **function_controller.go** `startBuild()`: аналогично
|
||||
- **functionjob_controller.go** `startJobBuild()`: аналогично; cache hit → Requeue для немедленного перехода к run
|
||||
- **v0.1.58** собран и задеплоен: `naeel/sless-operator:v0.1.58` Running, RESTARTS:0
|
||||
|
||||
### Статус
|
||||
✅ Код написан (no errors)
|
||||
✅ Docker build + push наDockerHub (sha256:7b0d48a01a29f2a5...)
|
||||
✅ kubectl rollout: `deployment "sless-operator" successfully rolled out`
|
||||
✅ Pod `sless-operator-85d4685c4b-6nxqw` Running v0.1.58
|
||||
|
||||
### Следующий шаг
|
||||
Протестировать cache hit: вернуть `.tf.disabled` → `.tf`, `terraform apply` — образы уже на DockerHub, должны восстановиться без kaniko за секунды.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
// 2026-03-21 — chaos_marathon.tf: 15 новых сервисов для часового хаос-марафона.
|
||||
// Три рантайма: python3.11 (9), nodejs20 (2), go1.23 (2).
|
||||
// Все зависят от sless_job.postgres_table_init_job.
|
||||
|
||||
# ── Python: работа с таблицей ─────────────────────────────────────────────────
|
||||
|
||||
# Считает строки по prefix — тест concurrent reads + COUNT агрегации.
|
||||
resource "sless_service" "pg_counter" {
|
||||
name = "pg-counter"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "pg_counter.count"
|
||||
memory_mb = 128
|
||||
timeout_sec = 15
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/pg-counter"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# DELETE дублей по title — идемпотентный, повторный вызов безопасен.
|
||||
resource "sless_service" "pg_dedup" {
|
||||
name = "pg-dedup"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "pg_dedup.dedup"
|
||||
memory_mb = 128
|
||||
timeout_sec = 30
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/pg-dedup"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# Поиск по title с ILIKE + пагинация — тест спецсимволов и SQL injection safety.
|
||||
resource "sless_service" "pg_search" {
|
||||
name = "pg-search"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "pg_search.search"
|
||||
memory_mb = 128
|
||||
timeout_sec = 15
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/pg-search"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# Bulk INSERT через execute_values — до 500 строк за раз.
|
||||
resource "sless_service" "pg_bulk_insert" {
|
||||
name = "pg-bulk-insert"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "pg_bulk_insert.bulk_insert"
|
||||
memory_mb = 256
|
||||
timeout_sec = 30
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/pg-bulk-insert"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# DELETE строк старше N минут — идемпотентный.
|
||||
resource "sless_service" "pg_delete_old" {
|
||||
name = "pg-delete-old"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "pg_delete_old.delete_old"
|
||||
memory_mb = 128
|
||||
timeout_sec = 30
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/pg-delete-old"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# INSERT ON CONFLICT DO UPDATE — повторный вызов с тем же title безопасен.
|
||||
resource "sless_service" "pg_upsert" {
|
||||
name = "pg-upsert"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "pg_upsert.upsert"
|
||||
memory_mb = 128
|
||||
timeout_sec = 15
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/pg-upsert"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# ── Python: chaos ─────────────────────────────────────────────────────────────
|
||||
|
||||
# Echo: принимает любой ввод и отражает обратно — проверка на мусорный input.
|
||||
resource "sless_service" "chaos_echo" {
|
||||
name = "chaos-echo"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "chaos_echo.echo"
|
||||
memory_mb = 128
|
||||
timeout_sec = 10
|
||||
|
||||
source_dir = "${path.module}/code/chaos-echo"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# Валидация плохих параметров — тупой юзер не может уронить сервис.
|
||||
resource "sless_service" "chaos_badparams" {
|
||||
name = "chaos-badparams"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "chaos_badparams.validate"
|
||||
memory_mb = 128
|
||||
timeout_sec = 10
|
||||
|
||||
source_dir = "${path.module}/code/chaos-badparams"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# Медленный pg_sleep — тест timeout enforcement.
|
||||
resource "sless_service" "chaos_slowquery" {
|
||||
name = "chaos-slowquery"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "chaos_slowquery.slowquery"
|
||||
memory_mb = 128
|
||||
timeout_sec = 12
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/chaos-slowquery"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# Большой JSON response — тест памяти и серилизации.
|
||||
resource "sless_service" "chaos_bigpayload" {
|
||||
name = "chaos-bigpayload"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "chaos_bigpayload.bigpayload"
|
||||
memory_mb = 256
|
||||
timeout_sec = 15
|
||||
|
||||
source_dir = "${path.module}/code/chaos-bigpayload"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# ── Node.js ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# Bulk INSERT через параметризованный multi-value query.
|
||||
resource "sless_service" "js_pg_batch" {
|
||||
name = "js-pg-batch"
|
||||
runtime = "nodejs20"
|
||||
entrypoint = "js_pg_batch.run"
|
||||
memory_mb = 128
|
||||
timeout_sec = 30
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/js-pg-batch"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# Идемпотентный INSERT — повторный вызов с тем же key = existing, не дубль.
|
||||
resource "sless_service" "js_idempotent" {
|
||||
name = "js-idempotent"
|
||||
runtime = "nodejs20"
|
||||
entrypoint = "js_idempotent.run"
|
||||
memory_mb = 128
|
||||
timeout_sec = 15
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/js-idempotent"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# ── Go 1.23 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# Параллельные concurrent INSERTs из N горутин внутри одного пода.
|
||||
resource "sless_service" "go_pg_race" {
|
||||
name = "go-pg-race"
|
||||
runtime = "go1.23"
|
||||
entrypoint = "handler.Handle"
|
||||
memory_mb = 256
|
||||
timeout_sec = 30
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/go-pg-race"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# Atomic-счётчик в памяти + PG INSERT на каждый вызов.
|
||||
resource "sless_service" "go_counter_atomic" {
|
||||
name = "go-counter-atomic"
|
||||
runtime = "go1.23"
|
||||
entrypoint = "handler.Handle"
|
||||
memory_mb = 128
|
||||
timeout_sec = 15
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/go-counter-atomic"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# ── Python: retry ─────────────────────────────────────────────────────────────
|
||||
|
||||
# Запись с retry при transient PG error — тест устойчивости к сбоям.
|
||||
resource "sless_service" "py_retry_writer" {
|
||||
name = "py-retry-writer"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "py_retry_writer.retry_write"
|
||||
memory_mb = 128
|
||||
timeout_sec = 30
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/py-retry-writer"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
# 2026-03-21 — chaos-badparams: проверяет что функция не падает на мусорных входных данных.
|
||||
# Принимает type=missing|wrong_type|huge|negative|zero и возвращает safe-ответ.
|
||||
# Тестирует: устойчивость к "тупому юзеру" — никакого 500 на плохих входных данных.
|
||||
import json
|
||||
|
||||
_MAX_N = 10_000
|
||||
|
||||
def validate(event):
|
||||
errors = []
|
||||
results = {}
|
||||
|
||||
# n: должно быть int от 1 до MAX_N
|
||||
raw_n = event.get("n")
|
||||
try:
|
||||
n = int(raw_n)
|
||||
if n <= 0:
|
||||
errors.append(f"n must be > 0, got {n}")
|
||||
n = 1
|
||||
elif n > _MAX_N:
|
||||
errors.append(f"n capped from {n} to {_MAX_N}")
|
||||
n = _MAX_N
|
||||
except (TypeError, ValueError):
|
||||
errors.append(f"n is not a valid int: {repr(raw_n)}, using default 1")
|
||||
n = 1
|
||||
results["n"] = n
|
||||
|
||||
# name: обрезаем до 100 символов
|
||||
raw_name = event.get("name", "")
|
||||
if not isinstance(raw_name, str):
|
||||
raw_name = str(raw_name)
|
||||
errors.append("name was not a string, converted")
|
||||
name = raw_name[:100]
|
||||
results["name"] = name
|
||||
|
||||
# flag: любое "truthy" значение
|
||||
raw_flag = event.get("flag", False)
|
||||
flag = raw_flag in (True, "true", "1", 1, "yes")
|
||||
results["flag"] = flag
|
||||
|
||||
return {"ok": len(errors) == 0, "errors": errors, "results": results}
|
||||
@@ -1,27 +0,0 @@
|
||||
# 2026-03-21 — chaos-bigpayload: генерирует/принимает большой JSON.
|
||||
# Тестирует: большие ответы (64KB+), память рантайма.
|
||||
import json, time
|
||||
|
||||
def bigpayload(event):
|
||||
size_kb = min(int(event.get("size_kb", 16)), 256) # cap 256KB
|
||||
word = str(event.get("word", "x"))[:32]
|
||||
|
||||
# Генерируем список строк нужного размера
|
||||
chunk = word * 32 # ~32+ байт на запись
|
||||
items = []
|
||||
total = 0
|
||||
target = size_kb * 1024
|
||||
i = 0
|
||||
while total < target:
|
||||
entry = f"{chunk}-{i}"
|
||||
items.append(entry)
|
||||
total += len(entry) + 3 # 3 байта JSON overhead
|
||||
i += 1
|
||||
|
||||
return {
|
||||
"items_count": len(items),
|
||||
"size_kb_approx": round(total / 1024, 1),
|
||||
"first": items[0] if items else "",
|
||||
"last": items[-1] if items else "",
|
||||
"ts": int(time.time()),
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
# 2026-03-21 — chaos-echo: отражает входные данные обратно.
|
||||
# Тестирует: большие payload, unicode, null, вложенные структуры, спецсимволы.
|
||||
# "Тупой юзер" шлёт всё что угодно — функция должна вернуть это обратно без падения.
|
||||
import json
|
||||
|
||||
def echo(event):
|
||||
# Пытаемся сериализовать обратно — выловит непериализуемые типы
|
||||
try:
|
||||
size = len(json.dumps(event))
|
||||
except Exception:
|
||||
size = -1
|
||||
|
||||
keys = list(event.keys()) if isinstance(event, dict) else []
|
||||
return {
|
||||
"echo": event,
|
||||
"keys": keys,
|
||||
"size_bytes": size,
|
||||
"type": type(event).__name__,
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
# 2026-03-21 — chaos-slowquery: намеренно медленный запрос через pg_sleep.
|
||||
# Тестирует: timeout enforcement — платформа должна прервать запрос если > timeout_sec.
|
||||
# sleep_sec cap = 8 (меньше timeout_sec=10 сервиса → успех; >10 → таймаут платформы).
|
||||
import os, psycopg2
|
||||
|
||||
def slowquery(event):
|
||||
sleep_sec = min(float(event.get("sleep_sec", 2.0)), 8.0)
|
||||
conn = psycopg2.connect(
|
||||
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
|
||||
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT pg_sleep(%s), now()::text", (sleep_sec,))
|
||||
result = cur.fetchone()
|
||||
return {"slept_sec": sleep_sec, "pg_now": result[1]}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary
|
||||
@@ -1,94 +0,0 @@
|
||||
# 2026-03-18 (обновлено: plain text вывод; фильтрация SLESS_EXCLUDE)
|
||||
# funcs_list.py — HTTP-функция: список пользовательских функций, человекочитаемый plain text.
|
||||
# Вызывает внутренний REST API оператора (ClusterIP, без TLS).
|
||||
# Возвращает str → python runtime отдаёт text/plain напрямую без json.dumps.
|
||||
#
|
||||
# Env vars:
|
||||
# SLESS_API_URL — URL оператора (http://sless-operator.sless.svc.cluster.local:9090)
|
||||
# SLESS_NAMESPACE — namespace пользователя (sless-{hex16})
|
||||
# SLESS_TOKEN — JWT токен для /v1/ API
|
||||
# SLESS_EXTERNAL_URL — публичный базовый URL (https://sless.kube5s.ru)
|
||||
# SLESS_EXCLUDE — comma-separated имена функций, которые не показывать
|
||||
|
||||
import os
|
||||
import requests
|
||||
|
||||
SEP = "─" * 52
|
||||
|
||||
|
||||
def _comment(fn, http_trigs, cron_trigs):
|
||||
phase = fn.get("phase", "?")
|
||||
runtime = fn.get("runtime", "?")
|
||||
if http_trigs:
|
||||
active = "активна" if http_trigs[0].get("active") else "неактивна"
|
||||
return f"HTTP endpoint ({runtime}) — {phase}, {active}"
|
||||
elif cron_trigs:
|
||||
schedule = cron_trigs[0].get("schedule", "?")
|
||||
active = "активна" if cron_trigs[0].get("active") else "неактивна"
|
||||
return f"Cron '{schedule}' ({runtime}) — {phase}, {active}"
|
||||
else:
|
||||
return f"Job/runner без триггера ({runtime}) — {phase}"
|
||||
|
||||
|
||||
def list_all(event):
|
||||
api_url = os.environ["SLESS_API_URL"].rstrip("/")
|
||||
namespace = os.environ["SLESS_NAMESPACE"]
|
||||
token = os.environ["SLESS_TOKEN"]
|
||||
ext_url = os.environ.get("SLESS_EXTERNAL_URL", "").rstrip("/")
|
||||
exclude = {n.strip() for n in os.environ.get("SLESS_EXCLUDE", "").split(",") if n.strip()}
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
fns = requests.get(f"{api_url}/v1/namespaces/{namespace}/functions", headers=headers, timeout=10)
|
||||
trs = requests.get(f"{api_url}/v1/namespaces/{namespace}/triggers", headers=headers, timeout=10)
|
||||
fns.raise_for_status()
|
||||
trs.raise_for_status()
|
||||
|
||||
trig_idx = {}
|
||||
for tr in trs.json():
|
||||
fn_name = tr.get("function") or tr.get("functionRef")
|
||||
if fn_name:
|
||||
trig_idx.setdefault(fn_name, []).append(tr)
|
||||
|
||||
items = []
|
||||
for fn in fns.json():
|
||||
name = fn["name"]
|
||||
if name in exclude:
|
||||
continue
|
||||
http_t = [t for t in trig_idx.get(name, []) if t.get("type") == "http"]
|
||||
cron_t = [t for t in trig_idx.get(name, []) if t.get("type") == "cron"]
|
||||
is_active = any(t.get("enabled", True) and t.get("active", False) for t in trig_idx.get(name, []))
|
||||
items.append((fn, http_t, cron_t, is_active))
|
||||
|
||||
# Сортировка: активные вверх, затем по имени
|
||||
items.sort(key=lambda x: (not x[3], x[0]["name"]))
|
||||
|
||||
lines = []
|
||||
for fn, http_t, cron_t, is_active in items:
|
||||
name = fn["name"]
|
||||
lines.append(SEP)
|
||||
lines.append(f" {_comment(fn, http_t, cron_t)}")
|
||||
lines.append(f" name: {name}")
|
||||
lines.append(f" runtime: {fn.get('runtime', '?')}")
|
||||
lines.append(f" phase: {fn.get('phase', '?')}")
|
||||
lines.append(f" active: {'да' if is_active else 'нет'}")
|
||||
|
||||
if http_t:
|
||||
url = f"{ext_url}/fn/{namespace}/{name}" if ext_url else http_t[0].get("url", "")
|
||||
lines.append(f" url: {url}")
|
||||
if cron_t:
|
||||
lines.append(f" cron: {cron_t[0].get('schedule', '?')}")
|
||||
if fn.get("created_at"):
|
||||
lines.append(f" created: {fn['created_at']}")
|
||||
if fn.get("last_built_at"):
|
||||
lines.append(f" built: {fn['last_built_at']}")
|
||||
if fn.get("message"):
|
||||
lines.append(f" message: {fn['message']}")
|
||||
|
||||
lines.append(SEP)
|
||||
lines.append(f" namespace: {namespace} | total: {len(items)}")
|
||||
lines.append(SEP)
|
||||
|
||||
# Возвращаем str — python runtime отдаст text/plain напрямую
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
requests==2.31.0
|
||||
@@ -1,55 +0,0 @@
|
||||
// 2026-03-21 — go-counter-atomic: считает вызовы через atomic в памяти + пишет в PG.
|
||||
// Тестирует: in-memory state между вызовами (Go pod остаётся живым), + PG INSERT на каждый вызов.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// invocations считается между вызовами (пока pod жив).
|
||||
var invocations int64
|
||||
|
||||
// Handle записывает факт вызова в PG и возвращает накопленный счётчик.
|
||||
func Handle(event map[string]interface{}) interface{} {
|
||||
n := atomic.AddInt64(&invocations, 1)
|
||||
|
||||
dsn := fmt.Sprintf(
|
||||
"host=%s port=%s dbname=%s user=%s password=%s sslmode=%s",
|
||||
os.Getenv("PGHOST"), envOrDefault("PGPORT", "5432"),
|
||||
os.Getenv("PGDATABASE"), os.Getenv("PGUSER"),
|
||||
os.Getenv("PGPASSWORD"), envOrDefault("PGSSLMODE", "require"),
|
||||
)
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"invocation_n": n, "error": err.Error()}
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
title := fmt.Sprintf("go-counter-invoke-%d-%d", n, time.Now().UnixMilli())
|
||||
var id int64
|
||||
err = pool.QueryRow(context.Background(),
|
||||
"INSERT INTO terraform_demo_table (title) VALUES ($1) RETURNING id", title,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"invocation_n": n, "error": err.Error()}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"invocation_n": n,
|
||||
"inserted_id": id,
|
||||
"title": title,
|
||||
}
|
||||
}
|
||||
|
||||
func envOrDefault(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
// 2026-03-21 — go-pg-race: параллельные INSERT из нескольких горутин внутри одной функции.
|
||||
// Тестирует: race condition устойчивость Go + PG при concurrent writes из одного пода.
|
||||
// Использует pgx/v5 (pre-cached в base image).
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Handle запускает workers горутин, каждая делает n_per_worker INSERTs.
|
||||
func Handle(event map[string]interface{}) interface{} {
|
||||
workers := intParam(event, "workers", 5)
|
||||
if workers > 20 {
|
||||
workers = 20
|
||||
}
|
||||
nPerWorker := intParam(event, "n_per_worker", 10)
|
||||
if nPerWorker > 50 {
|
||||
nPerWorker = 50
|
||||
}
|
||||
|
||||
dsn := fmt.Sprintf(
|
||||
"host=%s port=%s dbname=%s user=%s password=%s sslmode=%s",
|
||||
os.Getenv("PGHOST"), getenv("PGPORT", "5432"),
|
||||
os.Getenv("PGDATABASE"), os.Getenv("PGUSER"),
|
||||
os.Getenv("PGPASSWORD"), getenv("PGSSLMODE", "require"),
|
||||
)
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": err.Error()}
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
ok int64
|
||||
errCount int64
|
||||
)
|
||||
t0 := time.Now()
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(wid int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < nPerWorker; i++ {
|
||||
title := fmt.Sprintf("go-race-w%d-%d-%d", wid, time.Now().UnixMilli(), i)
|
||||
_, err := pool.Exec(context.Background(),
|
||||
"INSERT INTO terraform_demo_table (title) VALUES ($1)", title)
|
||||
if err != nil {
|
||||
atomic.AddInt64(&errCount, 1)
|
||||
} else {
|
||||
atomic.AddInt64(&ok, 1)
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
elapsed := time.Since(t0).Seconds()
|
||||
|
||||
return map[string]interface{}{
|
||||
"workers": workers,
|
||||
"n_per_worker": nPerWorker,
|
||||
"inserted": ok,
|
||||
"errors": errCount,
|
||||
"elapsed_sec": elapsed,
|
||||
"ops_per_sec": float64(ok) / elapsed,
|
||||
}
|
||||
}
|
||||
|
||||
func intParam(event map[string]interface{}, key string, def int) int {
|
||||
v, ok := event[key]
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return int(val)
|
||||
case int:
|
||||
return val
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func getenv(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
// 2026-03-21 — js-pg-batch: вставляет N строк через parameterized bulk query.
|
||||
// Тестирует: async/await PG с пакетной вставкой, Node.js под нагрузкой.
|
||||
const { Client } = require('pg');
|
||||
|
||||
async function run(event) {
|
||||
const n = Math.min(parseInt(event.n ?? 20, 10) || 20, 200);
|
||||
const prefix = String(event.prefix ?? 'js-batch').slice(0, 40);
|
||||
|
||||
const client = new Client({
|
||||
host: process.env.PGHOST,
|
||||
port: parseInt(process.env.PGPORT ?? '5432'),
|
||||
database: process.env.PGDATABASE,
|
||||
user: process.env.PGUSER,
|
||||
password: process.env.PGPASSWORD,
|
||||
ssl: { rejectUnauthorized: false },
|
||||
});
|
||||
await client.connect();
|
||||
|
||||
try {
|
||||
const ts = Date.now();
|
||||
// Строим multi-value INSERT: INSERT INTO ... VALUES ($1), ($2), ...
|
||||
const placeholders = [];
|
||||
const values = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
placeholders.push(`($${i + 1})`);
|
||||
values.push(`${prefix}-${ts}-${i}`);
|
||||
}
|
||||
const sql = `INSERT INTO terraform_demo_table (title) VALUES ${placeholders.join(',')} RETURNING id`;
|
||||
const t0 = Date.now();
|
||||
const res = await client.query(sql, values);
|
||||
const elapsed = (Date.now() - t0) / 1000;
|
||||
|
||||
return {
|
||||
inserted: res.rowCount,
|
||||
first_id: res.rows[0]?.id ?? null,
|
||||
elapsed_sec: elapsed,
|
||||
};
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"name": "js-pg-batch",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"pg": "^8.11.3"
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
# 2026-03-21 — pg-bulk-insert: bulk INSERT через execute_values.
|
||||
# Тестирует: большие батчи (до 500 строк), производительность, память.
|
||||
import os, time, psycopg2, psycopg2.extras
|
||||
|
||||
def bulk_insert(event):
|
||||
try:
|
||||
n = max(0, min(int(event.get("n", 50)), 500)) # cap 500, min 0
|
||||
except (TypeError, ValueError):
|
||||
n = 50
|
||||
prefix = str(event.get("prefix", "bulk"))[:50]
|
||||
ts = int(time.time() * 1000)
|
||||
|
||||
# n=0 — граничный случай: вернуть сразу без обращения к PG.
|
||||
if n == 0:
|
||||
return {"inserted": 0, "first_id": None, "elapsed_sec": 0.0}
|
||||
|
||||
rows = [(f"{prefix}-{ts}-{i}",) for i in range(n)]
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
|
||||
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
try:
|
||||
t0 = time.time()
|
||||
with conn.cursor() as cur:
|
||||
psycopg2.extras.execute_values(
|
||||
cur,
|
||||
"INSERT INTO terraform_demo_table (title) VALUES %s RETURNING id",
|
||||
rows,
|
||||
page_size=100,
|
||||
)
|
||||
ids = [r[0] for r in cur.fetchall()]
|
||||
conn.commit()
|
||||
elapsed = round(time.time() - t0, 3)
|
||||
return {"inserted": len(ids), "first_id": ids[0] if ids else None, "elapsed_sec": elapsed}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary
|
||||
@@ -1,38 +0,0 @@
|
||||
# 2026-03-21 — pg-dedup: удаляет дубликаты по title, оставляет первый (min id).
|
||||
# Тестирует: DELETE с subquery, CTE, idempotency (повторный вызов безопасен).
|
||||
import os, psycopg2
|
||||
|
||||
def dedup(event):
|
||||
dry_run = str(event.get("dry_run", "false")).lower() in ("true", "1", "yes")
|
||||
conn = psycopg2.connect(
|
||||
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
|
||||
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# Считаем сколько дублей есть
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM terraform_demo_table t1
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM terraform_demo_table t2
|
||||
WHERE t2.title = t1.title AND t2.id < t1.id
|
||||
)
|
||||
""")
|
||||
dupes_count = cur.fetchone()[0]
|
||||
|
||||
if not dry_run and dupes_count > 0:
|
||||
cur.execute("""
|
||||
DELETE FROM terraform_demo_table
|
||||
WHERE id NOT IN (
|
||||
SELECT MIN(id) FROM terraform_demo_table GROUP BY title
|
||||
)
|
||||
""")
|
||||
deleted = cur.rowcount
|
||||
conn.commit()
|
||||
else:
|
||||
deleted = 0
|
||||
|
||||
return {"duplicates_found": dupes_count, "deleted": deleted, "dry_run": dry_run}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary
|
||||
@@ -1,33 +0,0 @@
|
||||
# 2026-03-21 — pg-delete-old: удаляет строки старше N минут (default 60).
|
||||
# Тестирует: DELETE с RETURNING, идемпотентность (повторный вызов = 0 удалений если нет старых).
|
||||
import os, psycopg2, psycopg2.extras
|
||||
|
||||
def delete_old(event):
|
||||
older_than_min = max(int(event.get("older_than_min", 60)), 1)
|
||||
prefix_filter = event.get("prefix", "")
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
|
||||
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
try:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
if prefix_filter:
|
||||
cur.execute(
|
||||
"DELETE FROM terraform_demo_table "
|
||||
"WHERE created_at < now() - interval '1 minute' * %s "
|
||||
"AND title LIKE %s RETURNING id, title",
|
||||
(older_than_min, f"{prefix_filter}%"),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"DELETE FROM terraform_demo_table "
|
||||
"WHERE created_at < now() - interval '1 minute' * %s RETURNING id, title",
|
||||
(older_than_min,),
|
||||
)
|
||||
deleted = [dict(r) for r in cur.fetchall()]
|
||||
conn.commit()
|
||||
return {"deleted": len(deleted), "older_than_min": older_than_min, "sample": deleted[:5]}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary
|
||||
@@ -1,36 +0,0 @@
|
||||
# 2026-03-21 — pg-search: полнотекстовый поиск по title через ILIKE + LIMIT/OFFSET.
|
||||
# Тестирует: пагинацию, спецсимволы в input (XSS, SQL injection attempt → безопасно через параметры).
|
||||
import os, psycopg2, psycopg2.extras
|
||||
|
||||
def search(event):
|
||||
# «query» — основной параметр (user-friendly), «q» — алиас для совместимости.
|
||||
query = str(event.get("query") or event.get("q") or "")[:200]
|
||||
# int() может упасть если юзер прислал строку — защищаем try/except.
|
||||
try:
|
||||
limit = max(1, min(int(event.get("limit", 20)), 100))
|
||||
except (TypeError, ValueError):
|
||||
limit = 20
|
||||
try:
|
||||
offset = max(0, int(event.get("offset", 0)))
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
|
||||
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
try:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
pattern = f"%{query}%" if query else "%"
|
||||
cur.execute(
|
||||
"SELECT id, title, created_at::text FROM terraform_demo_table "
|
||||
"WHERE title ILIKE %s ORDER BY id DESC LIMIT %s OFFSET %s",
|
||||
(pattern, limit, offset),
|
||||
)
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
cur.execute("SELECT COUNT(*) FROM terraform_demo_table WHERE title ILIKE %s", (pattern,))
|
||||
total = cur.fetchone()["count"]
|
||||
return {"rows": rows, "count": len(rows), "total": total, "q": query, "limit": limit, "offset": offset}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary
|
||||
@@ -1,36 +0,0 @@
|
||||
# 2026-03-21 — pg-upsert: INSERT ... ON CONFLICT (title) DO UPDATE.
|
||||
# Тестирует: идемпотентность вставки — один и тот же title можно вызывать 100 раз подряд.
|
||||
# Требует уникального индекса на title — создаётся при первом вызове (CREATE UNIQUE INDEX IF NOT EXISTS).
|
||||
import os, psycopg2
|
||||
|
||||
def upsert(event):
|
||||
title = str(event.get("title", "upsert-default"))[:255]
|
||||
payload = str(event.get("payload", ""))[:500]
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
|
||||
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# Создаём уникальный индекс если нет — для поддержки ON CONFLICT
|
||||
cur.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS terraform_demo_table_title_uniq "
|
||||
"ON terraform_demo_table (title)"
|
||||
)
|
||||
cur.execute(
|
||||
"INSERT INTO terraform_demo_table (title) VALUES (%s) "
|
||||
"ON CONFLICT (title) DO UPDATE SET created_at = now() "
|
||||
"RETURNING id, title, created_at::text, xmax",
|
||||
(title,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
was_insert = row[3] == 0 # xmax=0 означает INSERT, иначе UPDATE
|
||||
conn.commit()
|
||||
return {
|
||||
"id": row[0], "title": row[1], "created_at": row[2],
|
||||
"action": "inserted" if was_insert else "updated",
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary
|
||||
@@ -1,54 +0,0 @@
|
||||
# 2026-03-21 — py-retry-writer: пишет N строк с retry при PG ошибке.
|
||||
# Тестирует: устойчивость к transient PG errors (simulate_error=true), retry logic,
|
||||
# корректный rollback при частичном сбое.
|
||||
import os, time, psycopg2, random
|
||||
|
||||
_MAX_RETRIES = 3
|
||||
|
||||
def retry_write(event):
|
||||
n = min(int(event.get("n", 5)), 100)
|
||||
prefix = str(event.get("prefix", "retry"))[:40]
|
||||
# simulate_error: с вероятностью 30% кидает OperationalError на 2-й попытке
|
||||
simulate = str(event.get("simulate_error", "false")).lower() in ("true", "1")
|
||||
|
||||
attempt = 0
|
||||
last_err = None
|
||||
|
||||
while attempt < _MAX_RETRIES:
|
||||
attempt += 1
|
||||
try:
|
||||
conn = psycopg2.connect(
|
||||
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
|
||||
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
inserted = []
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
for i in range(n):
|
||||
# Симуляция: на первой попытке падаем с вероятностью 50%
|
||||
if simulate and attempt == 1 and i == n // 2:
|
||||
raise psycopg2.OperationalError("simulated transient error")
|
||||
title = f"{prefix}-{int(time.time()*1000)}-{i}-a{attempt}"
|
||||
cur.execute(
|
||||
"INSERT INTO terraform_demo_table (title) VALUES (%s) RETURNING id",
|
||||
(title,),
|
||||
)
|
||||
inserted.append(cur.fetchone()[0])
|
||||
conn.commit()
|
||||
return {
|
||||
"ok": True, "inserted": len(inserted),
|
||||
"attempts": attempt, "first_id": inserted[0] if inserted else None,
|
||||
}
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
except psycopg2.OperationalError as e:
|
||||
last_err = str(e)
|
||||
if attempt < _MAX_RETRIES:
|
||||
time.sleep(0.3 * attempt) # exponential backoff
|
||||
continue
|
||||
|
||||
return {"ok": False, "attempts": attempt, "last_error": last_err}
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary
|
||||
@@ -1,3 +0,0 @@
|
||||
# 2026-03-17 00:00
|
||||
# requirements.txt — зависимости для функции запуска SQL.
|
||||
psycopg2-binary==2.9.9
|
||||
@@ -1,39 +0,0 @@
|
||||
# 2026-03-17 00:00
|
||||
# sql_runner.py — функция для выполнения SQL-операторов из входного события.
|
||||
import os
|
||||
import psycopg2
|
||||
|
||||
|
||||
def run_sql(event):
|
||||
# Выполняет список SQL-операторов в одной транзакции для атомарной инициализации схемы.
|
||||
# Параметры подключения передаются раздельно, чтобы избежать ошибок парсинга DSN при спецсимволах.
|
||||
pg_host = os.environ["PGHOST"]
|
||||
pg_port = os.environ.get("PGPORT", "5432")
|
||||
pg_database = os.environ["PGDATABASE"]
|
||||
pg_user = os.environ["PGUSER"]
|
||||
pg_password = os.environ["PGPASSWORD"]
|
||||
pg_sslmode = os.environ.get("PGSSLMODE", "require")
|
||||
statements = event.get("statements", [])
|
||||
|
||||
if not statements:
|
||||
return {"error": "no statements provided"}
|
||||
|
||||
connection = psycopg2.connect(
|
||||
host=pg_host,
|
||||
port=pg_port,
|
||||
dbname=pg_database,
|
||||
user=pg_user,
|
||||
password=pg_password,
|
||||
sslmode=pg_sslmode,
|
||||
)
|
||||
try:
|
||||
cursor = connection.cursor()
|
||||
for statement in statements:
|
||||
cursor.execute(statement)
|
||||
connection.commit()
|
||||
return {"ok": True, "executed": len(statements)}
|
||||
except Exception as error:
|
||||
connection.rollback()
|
||||
return {"error": str(error)}
|
||||
finally:
|
||||
connection.close()
|
||||
@@ -1,20 +0,0 @@
|
||||
# 2026-03-19
|
||||
# stress_bigloop.py — CPU-интенсивная функция: считает сумму квадратов N чисел.
|
||||
# Проверяет поведение под нагрузкой (большая и средняя итерация).
|
||||
|
||||
import time
|
||||
|
||||
_VERSION = "v1"
|
||||
|
||||
|
||||
def run(event):
|
||||
n = int(event.get("n", 500_000))
|
||||
start = time.monotonic()
|
||||
total = sum(i * i for i in range(n))
|
||||
elapsed = round(time.monotonic() - start, 4)
|
||||
return {
|
||||
"version": _VERSION,
|
||||
"n": n,
|
||||
"sum_of_squares": total,
|
||||
"elapsed_sec": elapsed,
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
# 2026-03-19
|
||||
# stress_divzero.py — намеренно делит на ноль (ZeroDivisionError).
|
||||
# Проверяет: платформа перехватывает панику, возвращает HTTP 500, не роняет под.
|
||||
|
||||
_VERSION = "v1"
|
||||
|
||||
|
||||
def run(event):
|
||||
numerator = int(event.get("n", 42))
|
||||
denominator = int(event.get("d", 0)) # по умолчанию 0 — намеренный краш
|
||||
# ZeroDivisionError: проверяем что платформа обрабатывает исключения
|
||||
result = numerator / denominator
|
||||
return {"version": _VERSION, "result": result}
|
||||
@@ -1,42 +0,0 @@
|
||||
// 2026-03-19
|
||||
// handler.go — быстрая Go функция: факториал + числа Фибоначчи.
|
||||
// Проверяет Go runtime под лёгкой нагрузкой и корректность JSON-ответа.
|
||||
// Entrypoint: handler.Handle
|
||||
package handler
|
||||
|
||||
import "fmt"
|
||||
|
||||
func factorial(n int) uint64 {
|
||||
if n <= 1 {
|
||||
return 1
|
||||
}
|
||||
return uint64(n) * factorial(n-1)
|
||||
}
|
||||
|
||||
func fib(n int) int {
|
||||
if n <= 1 {
|
||||
return n
|
||||
}
|
||||
a, b := 0, 1
|
||||
for i := 2; i <= n; i++ {
|
||||
a, b = b, a+b
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func Handle(event map[string]interface{}) interface{} {
|
||||
n := 10
|
||||
if v, ok := event["n"].(float64); ok {
|
||||
n = int(v)
|
||||
if n > 20 {
|
||||
n = 20
|
||||
}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"runtime": "go1.23",
|
||||
"version": "v1",
|
||||
"n": n,
|
||||
"factorial": fmt.Sprintf("%d", factorial(n)),
|
||||
"fib": fib(n),
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// 2026-03-19
|
||||
// handler.go — намеренный nil pointer dereference в Go.
|
||||
// Проверяет что Go runtime recover() перехватывает панику и платформа возвращает 500.
|
||||
// Entrypoint: handler.Handle
|
||||
package handler
|
||||
|
||||
func Handle(event map[string]interface{}) interface{} {
|
||||
crash := true
|
||||
if v, ok := event["crash"].(bool); ok {
|
||||
crash = v
|
||||
}
|
||||
if crash {
|
||||
var p *string
|
||||
_ = *p // panic: намеренный nil pointer для stress-теста
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"runtime": "go1.23",
|
||||
"version": "v1",
|
||||
"crashed": false,
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
// 2026-03-19
|
||||
// handler.go — Go стресс-тест PostgreSQL через pgxpool.
|
||||
// Запускает N горутин (default 100), каждая в цикле duration_sec (default 600)
|
||||
// долбит PG попеременно: INSERT / SELECT COUNT / SELECT MAX с случайными задержками.
|
||||
// Цель: проверить Go runtime под конкурентной нагрузкой и устойчивость PG connection pool.
|
||||
// Entrypoint: handler.Handle
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// pgDSN собирает DSN из env vars (PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, PGSSLMODE).
|
||||
func pgDSN() string {
|
||||
host := os.Getenv("PGHOST")
|
||||
port := os.Getenv("PGPORT")
|
||||
if port == "" {
|
||||
port = "5432"
|
||||
}
|
||||
db := os.Getenv("PGDATABASE")
|
||||
user := os.Getenv("PGUSER")
|
||||
pass := os.Getenv("PGPASSWORD")
|
||||
sslmode := os.Getenv("PGSSLMODE")
|
||||
if sslmode == "" {
|
||||
sslmode = "require"
|
||||
}
|
||||
return fmt.Sprintf("host=%s port=%s dbname=%s user=%s password=%s sslmode=%s",
|
||||
host, port, db, user, pass, sslmode)
|
||||
}
|
||||
|
||||
// worker — одна горутина: чередует INSERT/COUNT/MAX с случайной задержкой до maxDelayMs.
|
||||
// При ошибке инкрементирует errOps и продолжает (не паникует).
|
||||
func worker(ctx context.Context, pool *pgxpool.Pool, workerID int, maxDelayMs int, okOps, errOps *int64) {
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano() + int64(workerID)))
|
||||
op := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// Случайная задержка перед следующей операцией: 0..maxDelayMs мс
|
||||
delay := rng.Intn(maxDelayMs + 1)
|
||||
time.Sleep(time.Duration(delay) * time.Millisecond)
|
||||
|
||||
var err error
|
||||
switch op % 3 {
|
||||
case 0: // INSERT
|
||||
title := fmt.Sprintf("pgstorm-w%d-%d", workerID, time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx,
|
||||
"INSERT INTO terraform_demo_table (title) VALUES ($1)", title)
|
||||
case 1: // SELECT COUNT
|
||||
var count int64
|
||||
err = pool.QueryRow(ctx,
|
||||
"SELECT COUNT(*) FROM terraform_demo_table").Scan(&count)
|
||||
case 2: // SELECT MAX id
|
||||
var maxID *int64
|
||||
err = pool.QueryRow(ctx,
|
||||
"SELECT MAX(id) FROM terraform_demo_table").Scan(&maxID)
|
||||
}
|
||||
|
||||
if err != nil && ctx.Err() == nil {
|
||||
atomic.AddInt64(errOps, 1)
|
||||
} else if err == nil {
|
||||
atomic.AddInt64(okOps, 1)
|
||||
}
|
||||
op++
|
||||
}
|
||||
}
|
||||
|
||||
func Handle(event map[string]interface{}) interface{} {
|
||||
// Параметры из event (все опциональны — разумные defaults)
|
||||
workers := 100
|
||||
if v, ok := event["workers"].(float64); ok && v > 0 && v <= 500 {
|
||||
workers = int(v)
|
||||
}
|
||||
durationSec := 600
|
||||
if v, ok := event["duration_sec"].(float64); ok && v > 0 && v <= 3600 {
|
||||
durationSec = int(v)
|
||||
}
|
||||
maxDelayMs := 300
|
||||
if v, ok := event["max_delay_ms"].(float64); ok && v >= 0 && v <= 5000 {
|
||||
maxDelayMs = int(v)
|
||||
}
|
||||
|
||||
// Инициализация pgxpool — единый pool на всю функцию, MaxConns ограничен
|
||||
// чтобы не перегрузить managed PG при большом числе горутин.
|
||||
poolCfg, err := pgxpool.ParseConfig(pgDSN())
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("parse dsn: %v", err)}
|
||||
}
|
||||
maxConns := 20
|
||||
if workers < 20 {
|
||||
maxConns = workers
|
||||
}
|
||||
poolCfg.MaxConns = int32(maxConns)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(durationSec)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("connect pool: %v", err)}
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
var okOps, errOps int64
|
||||
startTime := time.Now()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
worker(ctx, pool, id, maxDelayMs, &okOps, &errOps)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
elapsed := time.Since(startTime).Seconds()
|
||||
total := okOps + errOps
|
||||
opsPerSec := 0.0
|
||||
if elapsed > 0 {
|
||||
opsPerSec = float64(total) / elapsed
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"runtime": "go1.23",
|
||||
"version": "v1",
|
||||
"workers": workers,
|
||||
"duration_sec": durationSec,
|
||||
"max_delay_ms": maxDelayMs,
|
||||
"elapsed_sec": fmt.Sprintf("%.1f", elapsed),
|
||||
"total_ops": total,
|
||||
"ok_ops": okOps,
|
||||
"err_ops": errOps,
|
||||
"ops_per_sec": fmt.Sprintf("%.1f", opsPerSec),
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"name": "stress-js-async",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"pg": "^8.11.0"
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// 2026-03-19
|
||||
// stress_js_async.js — делает 3 параллельных запроса к PG через Promise.all.
|
||||
// Проверяет nodejs20 runtime под умеренной нагрузкой и async/await.
|
||||
//
|
||||
// Entrypoint: stress_js_async.run
|
||||
|
||||
'use strict';
|
||||
|
||||
const { Client } = require('pg');
|
||||
|
||||
exports.run = async (event) => {
|
||||
const client = new Client({
|
||||
host: process.env.PGHOST,
|
||||
port: parseInt(process.env.PGPORT || '5432'),
|
||||
database: process.env.PGDATABASE,
|
||||
user: process.env.PGUSER,
|
||||
password: process.env.PGPASSWORD,
|
||||
ssl: process.env.PGSSLMODE === 'require' ? { rejectUnauthorized: false } : false,
|
||||
});
|
||||
await client.connect();
|
||||
try {
|
||||
const [ver, cnt, max] = await Promise.all([
|
||||
client.query('SELECT version() AS v'),
|
||||
client.query('SELECT COUNT(*) AS cnt FROM terraform_demo_table'),
|
||||
client.query('SELECT MAX(id) AS max_id FROM terraform_demo_table'),
|
||||
]);
|
||||
return {
|
||||
runtime: 'nodejs20',
|
||||
version: 'v1',
|
||||
pg_version: ver.rows[0].v.split(' ').slice(0, 2).join(' '),
|
||||
total_rows: parseInt(cnt.rows[0].cnt, 10),
|
||||
max_id: max.rows[0].max_id,
|
||||
};
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"name": "stress-js-badenv",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// 2026-03-19
|
||||
// stress_js_badenv.js — читает несуществующую переменную env и падает.
|
||||
// Проверяет: платформа перехватывает TypeError/undefined, возвращает 500.
|
||||
//
|
||||
// Entrypoint: stress_js_badenv.run
|
||||
|
||||
'use strict';
|
||||
|
||||
exports.run = async (event) => {
|
||||
const crash = event.crash !== false; // по умолчанию crash=true
|
||||
if (crash) {
|
||||
// Читаем несуществующий env, пытаемся вызвать .toUpperCase() на undefined
|
||||
const val = process.env.THIS_VAR_DOES_NOT_EXIST_AT_ALL;
|
||||
return { shout: val.toUpperCase() }; // TypeError: Cannot read properties of undefined
|
||||
}
|
||||
return { runtime: 'nodejs20', version: 'v1', crashed: false };
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
# 2026-03-19
|
||||
# stress_slow.py — долгая функция: спит N секунд (по умолчанию 8).
|
||||
# Проверяет что timeout-механизм и параллельные запросы не блокируют друг друга.
|
||||
|
||||
import time
|
||||
import os
|
||||
|
||||
_VERSION = "v1"
|
||||
|
||||
|
||||
def run(event):
|
||||
secs = int(event.get("sleep", 8))
|
||||
time.sleep(secs)
|
||||
return {
|
||||
"version": _VERSION,
|
||||
"slept_sec": secs,
|
||||
"pid": os.getpid(),
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary==2.9.9
|
||||
@@ -1,39 +0,0 @@
|
||||
# 2026-03-19
|
||||
# stress_writer.py — пишет N строк в terraform_demo_table (по умолчанию 5).
|
||||
# Проверяет параллельные INSERT'ы и устойчивость соединения с PG при нагрузке.
|
||||
|
||||
import os
|
||||
import psycopg2
|
||||
import time
|
||||
|
||||
_VERSION = "v1"
|
||||
|
||||
|
||||
def run(event):
|
||||
n = int(event.get("rows", 5))
|
||||
prefix = event.get("prefix", "stress")
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=os.environ["PGHOST"],
|
||||
port=int(os.environ.get("PGPORT", "5432")),
|
||||
dbname=os.environ["PGDATABASE"],
|
||||
user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"],
|
||||
sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
inserted = []
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
for i in range(n):
|
||||
title = f"{prefix}-{int(time.time()*1000)}-{i}"
|
||||
cur.execute(
|
||||
"INSERT INTO terraform_demo_table (title) VALUES (%s) RETURNING id",
|
||||
(title,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
inserted.append({"id": row[0], "title": title})
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {"version": _VERSION, "inserted": inserted, "count": len(inserted)}
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary==2.9.9
|
||||
@@ -1,133 +0,0 @@
|
||||
# 2026-03-19 — добавлен version и hostname в ответ list_rows для тестирования обновления кода
|
||||
# table_rw.py — чтение и запись строк в terraform_demo_table.
|
||||
# Два entrypoint в одном файле: list_rows (JSON API) и add_row (HTML-страница + POST-обработчик).
|
||||
# ENV: PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, PGSSLMODE
|
||||
|
||||
import os
|
||||
import json
|
||||
import socket
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
_CODE_VERSION = "v2-with-hostname"
|
||||
|
||||
|
||||
def _connect():
|
||||
return psycopg2.connect(
|
||||
host=os.environ["PGHOST"],
|
||||
port=os.environ.get("PGPORT", "5432"),
|
||||
dbname=os.environ["PGDATABASE"],
|
||||
user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"],
|
||||
sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
|
||||
|
||||
def list_rows(event):
|
||||
# Возвращает все строки terraform_demo_table, отсортированные по убыванию created_at.
|
||||
conn = _connect()
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(
|
||||
"SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC"
|
||||
)
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
return {"rows": rows, "count": len(rows), "version": _CODE_VERSION, "host": socket.gethostname()}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _render_page(rows, message=""):
|
||||
# HTML-страница с формой ввода и таблицей строк.
|
||||
# message — статус последней операции (успех / ошибка).
|
||||
rows_html = "".join(
|
||||
f"<tr><td>{r['id']}</td><td>{r['title']}</td><td>{r['created_at']}</td></tr>"
|
||||
for r in rows
|
||||
)
|
||||
msg_html = f'<p class="msg">{message}</p>' if message else ""
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>pg-table-writer</title>
|
||||
<style>
|
||||
body {{ font-family: sans-serif; max-width: 700px; margin: 40px auto; background: #111; color: #eee; }}
|
||||
h1 {{ color: #7dd3fc; }}
|
||||
form {{ display: flex; gap: 8px; margin-bottom: 24px; }}
|
||||
input[type=text] {{ flex: 1; padding: 8px 12px; border-radius: 6px; border: 1px solid #444; background: #1e1e1e; color: #eee; font-size: 15px; }}
|
||||
button {{ padding: 8px 18px; background: #2563eb; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 15px; }}
|
||||
button:hover {{ background: #1d4ed8; }}
|
||||
table {{ width: 100%; border-collapse: collapse; }}
|
||||
th, td {{ padding: 8px 10px; border-bottom: 1px solid #333; text-align: left; }}
|
||||
th {{ color: #7dd3fc; }}
|
||||
.msg {{ color: #4ade80; margin-bottom: 12px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>pg-table-writer</h1>
|
||||
<form method="POST">
|
||||
<input type="text" name="title" placeholder="Введите строку..." autofocus required>
|
||||
<button type="submit">Добавить</button>
|
||||
</form>
|
||||
{msg_html}
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>title</th><th>created_at</th></tr></thead>
|
||||
<tbody>{rows_html}</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def add_row(event):
|
||||
# GET → HTML-страница с формой и списком строк.
|
||||
# POST → вставляет строку из form-поля title или JSON-поля title,
|
||||
# затем возвращает обновлённую HTML-страницу.
|
||||
# POST с Content-Type: application/json (curl/API) → возвращает JSON.
|
||||
method = event.get("_method", "GET")
|
||||
|
||||
if method == "GET":
|
||||
conn = _connect()
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute("SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC")
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
return _render_page(rows)
|
||||
|
||||
# POST — вставка строки
|
||||
# Поле title приходит либо из JSON-тела, либо из application/x-www-form-urlencoded.
|
||||
# Сервер уже распарсил JSON в event; form-данные приходят как event["body"] = "title=...".
|
||||
title = event.get("title", "").strip()
|
||||
if not title:
|
||||
# Попытка распарсить form-encoded body (браузерная форма)
|
||||
body = event.get("body", "")
|
||||
if body.startswith("title="):
|
||||
from urllib.parse import unquote_plus
|
||||
title = unquote_plus(body[len("title="):].split("&")[0]).strip()
|
||||
|
||||
if not title:
|
||||
return {"ok": False, "error": "title is required"}
|
||||
|
||||
conn = _connect()
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(
|
||||
"INSERT INTO terraform_demo_table (title) VALUES (%s) RETURNING id, title, created_at::text",
|
||||
(title,),
|
||||
)
|
||||
row = dict(cur.fetchone())
|
||||
conn.commit()
|
||||
|
||||
# Если запрос из браузера (form POST) — возвращаем обновлённую страницу.
|
||||
# Если из curl/API — возвращаем JSON.
|
||||
accept = event.get("_accept", "")
|
||||
if "application/json" in accept:
|
||||
return {"ok": True, "row": row}
|
||||
|
||||
# Перечитываем все строки для обновлённой страницы
|
||||
cur.execute("SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC")
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
return _render_page(rows, message=f"Добавлено: «{row['title']}»")
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1,105 +0,0 @@
|
||||
// 2026-03-20 (merge: sless_function + старый sless_job объединены в один self-contained sless_job)
|
||||
// Теперь sless_job несёт в себе runtime/entrypoint/source_dir — не нужен отдельный sless_function.
|
||||
// WaitJobDone таймаут 900s покрывает kaniko сборку (~5 мин) + выполнение SQL (~несколько сек).
|
||||
|
||||
# Одноразовый запуск: собирает образ через kaniko, выполняет SQL, завершается.
|
||||
# Заменяет sless_function.postgres_sql_runner_create_table + sless_job.postgres_table_init_job.
|
||||
resource "sless_job" "postgres_table_init_job" {
|
||||
name = "pg-create-table-job-main-v13"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "sql_runner.run_sql"
|
||||
memory_mb = 128
|
||||
timeout_sec = 30
|
||||
source_dir = "${path.module}/code/sql-runner"
|
||||
wait_timeout_sec = 900
|
||||
run_id = 13
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
event_json = jsonencode({
|
||||
statements = [
|
||||
"CREATE TABLE IF NOT EXISTS terraform_demo_table (id serial PRIMARY KEY, title text NOT NULL, created_at timestamp DEFAULT now())"
|
||||
]
|
||||
})
|
||||
|
||||
depends_on = [nubes_postgres_database.db]
|
||||
}
|
||||
|
||||
# Long-running сервис на NodeJS: возвращает версию PG-сервера и счётчик строк в таблице.
|
||||
resource "sless_service" "pg_info" {
|
||||
name = "pg-info"
|
||||
runtime = "nodejs20"
|
||||
entrypoint = "pg_info.info"
|
||||
memory_mb = 128
|
||||
timeout_sec = 15
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/pg-info"
|
||||
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
resource "sless_service" "postgres_table_reader" {
|
||||
name = "pg-table-reader"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "table_rw.list_rows"
|
||||
memory_mb = 128
|
||||
timeout_sec = 30
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/table-rw"
|
||||
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
output "table_reader_url" {
|
||||
value = sless_service.postgres_table_reader.url
|
||||
}
|
||||
|
||||
resource "sless_service" "postgres_table_writer" {
|
||||
name = "pg-table-writer"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "table_rw.add_row"
|
||||
memory_mb = 256
|
||||
timeout_sec = 45
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/table-rw"
|
||||
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
output "table_writer_url" {
|
||||
value = sless_service.postgres_table_writer.url
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
// 2026-03-21 — stress.tf: все стресс-сервисы для комплексного тестирования.
|
||||
// Три рантайма: go1.23 (3), nodejs20 (2), python3.11 (5).
|
||||
// Все depends_on = [sless_job.postgres_table_init_job] — таблица должна существовать.
|
||||
|
||||
# ── Go 1.23 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
# Быстрая математика: факториал + числа Фибоначчи. Без PG. Проверяет Go runtime.
|
||||
resource "sless_service" "stress_go_fast" {
|
||||
name = "stress-go-fast"
|
||||
runtime = "go1.23"
|
||||
entrypoint = "handler.Handle"
|
||||
memory_mb = 128
|
||||
timeout_sec = 15
|
||||
source_dir = "${path.module}/code/stress-go-fast"
|
||||
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# Намеренный nil pointer dereference. Без PG. Проверяет recover() в Go runtime.
|
||||
resource "sless_service" "stress_go_nil" {
|
||||
name = "stress-go-nil"
|
||||
runtime = "go1.23"
|
||||
entrypoint = "handler.Handle"
|
||||
memory_mb = 128
|
||||
timeout_sec = 10
|
||||
source_dir = "${path.module}/code/stress-go-nil"
|
||||
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# Конкурентный PG-шторм через pgxpool: N горутин INSERT/COUNT/MAX.
|
||||
# timeout_sec=660 — покрывает max duration_sec=600 с запасом.
|
||||
# pgx/v5 уже в базовом образе go1.23: user go.mod не нужен.
|
||||
resource "sless_service" "stress_go_pgstorm" {
|
||||
name = "stress-go-pgstorm"
|
||||
runtime = "go1.23"
|
||||
entrypoint = "handler.Handle"
|
||||
memory_mb = 256
|
||||
timeout_sec = 660
|
||||
source_dir = "${path.module}/code/stress-go-pgstorm"
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# ── Node.js 20 ────────────────────────────────────────────────────────────────
|
||||
|
||||
# 3 параллельных PG-запроса через Promise.all. Проверяет async/await + nodejs20.
|
||||
resource "sless_service" "stress_js_async" {
|
||||
name = "stress-js-async"
|
||||
runtime = "nodejs20"
|
||||
entrypoint = "stress_js_async.run"
|
||||
memory_mb = 128
|
||||
timeout_sec = 20
|
||||
source_dir = "${path.module}/code/stress-js-async"
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# TypeError через undefined.toUpperCase(). Без PG. Проверяет перехват JS-ошибок.
|
||||
resource "sless_service" "stress_js_badenv" {
|
||||
name = "stress-js-badenv"
|
||||
runtime = "nodejs20"
|
||||
entrypoint = "stress_js_badenv.run"
|
||||
memory_mb = 128
|
||||
timeout_sec = 10
|
||||
source_dir = "${path.module}/code/stress-js-badenv"
|
||||
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# ── Python 3.11 ───────────────────────────────────────────────────────────────
|
||||
|
||||
# Спит N секунд. Без PG. Проверяет timeout и сосуществование долгих запросов.
|
||||
resource "sless_service" "stress_slow" {
|
||||
name = "stress-slow"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "stress_slow.run"
|
||||
memory_mb = 128
|
||||
timeout_sec = 35
|
||||
source_dir = "${path.module}/code/stress-slow"
|
||||
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# CPU-нагрузка: сумма квадратов N чисел. Без PG. Проверяет compute-bound задачи.
|
||||
resource "sless_service" "stress_bigloop" {
|
||||
name = "stress-bigloop"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "stress_bigloop.run"
|
||||
memory_mb = 256
|
||||
timeout_sec = 60
|
||||
source_dir = "${path.module}/code/stress-bigloop"
|
||||
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# ZeroDivisionError. Без PG. Проверяет перехват Python-исключений → HTTP 500.
|
||||
resource "sless_service" "stress_divzero" {
|
||||
name = "stress-divzero"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "stress_divzero.run"
|
||||
memory_mb = 128
|
||||
timeout_sec = 10
|
||||
source_dir = "${path.module}/code/stress-divzero"
|
||||
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# Параллельный INSERT в terraform_demo_table через psycopg2. Проверяет PG-write.
|
||||
resource "sless_service" "stress_writer" {
|
||||
name = "stress-writer"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "stress_writer.run"
|
||||
memory_mb = 128
|
||||
timeout_sec = 60
|
||||
source_dir = "${path.module}/code/stress-writer"
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# Агрегированная статистика terraform_demo_table (COUNT/MIN/MAX). Для мониторинга.
|
||||
resource "sless_service" "pg_stats" {
|
||||
name = "pg-stats"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "pg_stats.get_stats"
|
||||
memory_mb = 128
|
||||
timeout_sec = 15
|
||||
source_dir = "${path.module}/code/pg-stats"
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
Executable
+176
@@ -0,0 +1,176 @@
|
||||
#!/bin/bash
|
||||
# test_cache_matrix.sh — 2026-03-23 (v4)
|
||||
# Комплексный тест кэша registry:
|
||||
# Phase 1 — полный деплой всех 24 ресурсов (kaniko builds, т.к. нет образов)
|
||||
# Phase 2 — destroy sless_* + re-apply (все образы из кэша)
|
||||
# Phase 3 — одновременно: удаление 2, смена кода 2, смена параметров 2
|
||||
# ВАЖНО: postgres.tf НЕ переименовывается и НЕ трогается никогда.
|
||||
# Destroy sless-ресурсов делается путём переименования tf-файлов в .tf.bak,
|
||||
# затем terraform apply (видит что ресурсов нет → удаляет их из state+кластера),
|
||||
# затем файлы возвращаются обратно. Никаких -target.
|
||||
|
||||
set -euo pipefail
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
LOG="$DIR/test_cache_matrix_$(date +%Y%m%d_%H%M%S).log"
|
||||
TIMINGS="$LOG.timings"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG"; }
|
||||
sep() { log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"; }
|
||||
|
||||
timed_op() {
|
||||
local label="$1"; shift
|
||||
log "▶ START: $label"
|
||||
local t0; t0=$(date +%s%3N)
|
||||
"$@" 2>&1 | tee -a "$LOG"
|
||||
local rc=${PIPESTATUS[0]}
|
||||
local t1; t1=$(date +%s%3N)
|
||||
local elapsed=$(( (t1 - t0) / 1000 ))
|
||||
if [[ $rc -eq 0 ]]; then
|
||||
log "✓ DONE: $label — ${elapsed}s"
|
||||
PASS=$((PASS+1))
|
||||
else
|
||||
log "✗ FAIL: $label — ${elapsed}s (exit $rc)"
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
echo "$label: ${elapsed}s" >> "$TIMINGS"
|
||||
return $rc
|
||||
}
|
||||
|
||||
destroy_sless_only() {
|
||||
# Переименовываем tf-файлы с sless-ресурсами в .tf.bak → terraform apply их удалит.
|
||||
# Никаких -target — чтобы не затрагивать postgres и не получать state drift.
|
||||
local label="$1"
|
||||
local SLESS_FILES=("chaos_marathon.tf" "functions.tf" "stress.tf")
|
||||
|
||||
local has_state
|
||||
has_state=$(terraform state list 2>/dev/null | grep -cE '^(sless_service|sless_job)' || true)
|
||||
if [[ "$has_state" -eq 0 ]]; then
|
||||
log " (nothing to destroy for $label — state empty)"
|
||||
return 0
|
||||
fi
|
||||
log " Hiding sless tf-files → apply will destroy $has_state resources"
|
||||
|
||||
for f in "${SLESS_FILES[@]}"; do
|
||||
[[ -f "$DIR/$f" ]] && mv "$DIR/$f" "$DIR/$f.bak"
|
||||
done
|
||||
|
||||
timed_op "$label" terraform apply -auto-approve
|
||||
|
||||
for f in "${SLESS_FILES[@]}"; do
|
||||
[[ -f "$DIR/$f.bak" ]] && mv "$DIR/$f.bak" "$DIR/$f"
|
||||
done
|
||||
log " sless tf-files restored"
|
||||
}
|
||||
|
||||
cd "$DIR"
|
||||
|
||||
sep
|
||||
log "PHASE 1: Полный начальный деплой"
|
||||
sep
|
||||
destroy_sless_only "phase1-pre-clean"
|
||||
timed_op "phase1-apply-all" terraform apply -auto-approve
|
||||
|
||||
log "--- Образы в registry после Phase 1 ---"
|
||||
kubectl exec -n sless deployment/sless-registry -- sh -c 'find /var/lib/registry -name "*.json" -path "*/tags/*" 2>/dev/null | sed "s|.*repository/||;s|/_manifests.*||" | sort | uniq -c | sort -rn' 2>/dev/null | head -30 | tee -a "$LOG" || log "(registry inspect failed)"
|
||||
|
||||
sep
|
||||
log "PHASE 2: Destroy sless_* → Re-apply (ожидаем cache hits)"
|
||||
sep
|
||||
destroy_sless_only "phase2-destroy"
|
||||
timed_op "phase2-apply-cached" terraform apply -auto-approve
|
||||
|
||||
sep
|
||||
log "PHASE 3: Mixed ops (delete+code+params)"
|
||||
sep
|
||||
|
||||
log "--- 3a: destroy stress_divzero, chaos_echo (comment out → apply → restore) ---"
|
||||
python3 - <<'PYEOF'
|
||||
import re, pathlib
|
||||
|
||||
def comment_out_resource(path, resource_type, resource_name):
|
||||
text = pathlib.Path(path).read_text()
|
||||
# Находим блок resource "type" "name" { ... } и оборачиваем в /* */
|
||||
pattern = rf'(resource\s+"{re.escape(resource_type)}"\s+"{re.escape(resource_name)}"\s*\{{)'
|
||||
match = re.search(pattern, text)
|
||||
if not match:
|
||||
print(f" WARNING: {resource_type}.{resource_name} not found in {path}")
|
||||
return
|
||||
# Найти закрывающую скобку блока
|
||||
start = match.start()
|
||||
depth = 0
|
||||
i = match.start()
|
||||
while i < len(text):
|
||||
if text[i] == '{': depth += 1
|
||||
elif text[i] == '}':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = i + 1
|
||||
break
|
||||
i += 1
|
||||
block = text[start:end]
|
||||
commented = "/* COMMENTED_OUT_FOR_TEST\n" + block + "\nCOMMENTED_OUT_FOR_TEST */"
|
||||
pathlib.Path(path).write_text(text[:start] + commented + text[end:])
|
||||
print(f" commented out: {resource_type}.{resource_name} in {path}")
|
||||
|
||||
comment_out_resource("stress.tf", "sless_service", "stress_divzero")
|
||||
comment_out_resource("chaos_marathon.tf", "sless_service", "chaos_echo")
|
||||
PYEOF
|
||||
timed_op "phase3a-destroy-2" terraform apply -auto-approve
|
||||
# Восстанавливаем закомментированные блоки
|
||||
python3 - <<'PYEOF'
|
||||
import pathlib, re
|
||||
|
||||
for fname in ("stress.tf", "chaos_marathon.tf"):
|
||||
p = pathlib.Path(fname)
|
||||
text = p.read_text()
|
||||
text = re.sub(r'/\* COMMENTED_OUT_FOR_TEST\n', '', text)
|
||||
text = re.sub(r'\nCOMMENTED_OUT_FOR_TEST \*/', '', text)
|
||||
p.write_text(text)
|
||||
print(f" restored: {fname}")
|
||||
PYEOF
|
||||
log " stress_divzero, chaos_echo removed from state and k8s"
|
||||
|
||||
log "--- 3b: code changes (new sha256 → kaniko) ---"
|
||||
echo "" >> "$DIR/code/pg-counter/pg_counter.py"
|
||||
echo "# cache-test-$(date +%s)" >> "$DIR/code/pg-counter/pg_counter.py"
|
||||
echo "" >> "$DIR/code/stress-js-async/stress_js_async.js"
|
||||
echo "// cache-test-$(date +%s)" >> "$DIR/code/stress-js-async/stress_js_async.js"
|
||||
log " changed: pg_counter.py, stress_js_async.js"
|
||||
|
||||
log "--- 3c: param changes (same sha256 → no kaniko) ---"
|
||||
python3 - <<'PYEOF'
|
||||
import re, sys
|
||||
with open("stress.tf") as f:
|
||||
content = f.read()
|
||||
orig = content
|
||||
content = re.sub(
|
||||
r'(resource "sless_service" "stress_slow" \{[^}]*?)memory_mb\s*=\s*\d+',
|
||||
lambda m: m.group(1) + 'memory_mb = 192',
|
||||
content, flags=re.DOTALL
|
||||
)
|
||||
content = re.sub(
|
||||
r'(resource "sless_service" "pg_stats" \{[^}]*?)timeout_sec\s*=\s*\d+',
|
||||
lambda m: m.group(1) + 'timeout_sec = 20',
|
||||
content, flags=re.DOTALL
|
||||
)
|
||||
if content == orig:
|
||||
print(" stress.tf: no changes (already patched?)", file=sys.stderr)
|
||||
else:
|
||||
with open("stress.tf", "w") as f:
|
||||
f.write(content)
|
||||
print(" stress.tf: stress_slow→memory_mb=192, pg_stats→timeout_sec=20")
|
||||
PYEOF
|
||||
|
||||
log "--- 3d: apply всех mixed изменений ---"
|
||||
log " Expected: stress_divzero+chaos_echo=cache_hit, pg_counter+stress_js_async=kaniko, stress_slow+pg_stats=k8s_only"
|
||||
timed_op "phase3d-mixed-apply" terraform apply -auto-approve
|
||||
|
||||
sep
|
||||
log "ИТОГ"
|
||||
sep
|
||||
log "Timings:"
|
||||
cat "$TIMINGS" 2>/dev/null | tee -a "$LOG"
|
||||
log "Pass: $PASS | Fail: $FAIL"
|
||||
log "Лог: $LOG"
|
||||
@@ -1,5 +1,6 @@
|
||||
// Изменено: 2026-03-18 (добавлены created_at, last_built_at в functionResponse и fnToResponse)
|
||||
// Изменено: 2026-03-21 (fix: DeleteFunction возвращает 404 вместо 204 при отсутствующем объекте)
|
||||
// Изменено: 2026-03-22 (fix: CreateFunction 409 при пересоздании функции — не учитывался DeletionTimestamp)
|
||||
// functions.go — CRUD handlers для Function CRD.
|
||||
// Принимает JSON, создаёт/обновляет/удаляет k8s ресурсы Function.
|
||||
// Namespace берётся из URL: /v1/namespaces/{namespace}/functions/{name}
|
||||
@@ -9,6 +10,7 @@ package handler
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -128,22 +130,59 @@ func (h *Handler) CreateFunction(w http.ResponseWriter, r *http.Request) {
|
||||
S3Key: req.S3Key,
|
||||
},
|
||||
}
|
||||
// Пытаемся создать Function CRD в k8s (запись в etcd через controller-runtime).
|
||||
if err := h.K8s.Create(r.Context(), fn); err != nil {
|
||||
|
||||
// IsAlreadyExists = etcd вернул 409 CONFLICT.
|
||||
// Возникает в двух основных сценариях:
|
||||
//
|
||||
// [A] terraform apply -replace (= delete + create за один apply):
|
||||
// 1. terraform DELETE /functions/{name} → API вызывает h.K8s.Delete(fn)
|
||||
// 2. k8s ставит DeletionTimestamp и ждёт снятия finalizer sless.kube5s.ru/finalizer
|
||||
// function_controller.go убивает kaniko Job и снимает finalizer асинхронно
|
||||
// 3. terraform сразу POST /functions/{name} → IsAlreadyExists ← БАГ до этого фикса
|
||||
// → НОВЫЙ КОД: обнаруживает DeletionTimestamp → ждёт исчезновения → создаёт
|
||||
//
|
||||
// [B] Split-brain кеша controller-runtime:
|
||||
// Объект удалён из etcd, но informer-кеш ещё не обновился.
|
||||
// Create падает с IsAlreadyExists из кеша, Get возвращает NotFound → пересоздаём.
|
||||
//
|
||||
// [C] Функция в фазе Failed:
|
||||
// Предыдущий build провалился. Новый apply пытается создать снова.
|
||||
// Удаляем Failed объект и пересоздаём.
|
||||
if errors.IsAlreadyExists(err) {
|
||||
// IsAlreadyExists может прийти из кеша controller-runtime (split-brain):
|
||||
// объект удалён из etcd, но кеш informer ещё южив. Делаем uncached Get:
|
||||
// если реально NotFound — кеш устарел, пересоздаём.
|
||||
// если существует и фаза Failed — тоже пересоздаём (build провалился, терраформ не добавил в state).
|
||||
// если существует и фаза Ready/Building — возвращаем 409 (функция реально есть).
|
||||
|
||||
// Получаем актуальное состояние объекта из etcd (не из кеша informer).
|
||||
existing := &slessv1alpha1.Function{}
|
||||
getErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing)
|
||||
shouldRecreate := errors.IsNotFound(getErr) ||
|
||||
(getErr == nil && existing.Status.Phase == slessv1alpha1.FunctionPhaseFailed)
|
||||
if shouldRecreate {
|
||||
if getErr == nil {
|
||||
_ = h.K8s.Delete(r.Context(), existing)
|
||||
|
||||
// --- Сценарий A: объект ожидает удаления ---
|
||||
// DeletionTimestamp ≠ zero = k8s принял DELETE, finalizer ещё не снят.
|
||||
// function_controller.go снимает finalizer после cleanup Job — обычно 1-5 сек.
|
||||
if getErr == nil && !existing.DeletionTimestamp.IsZero() {
|
||||
|
||||
deleted := false
|
||||
// Polling каждую секунду, максимум 30 раз (= 30 секунд).
|
||||
// 30 сек — запас на медленный кластер; в норме 1-3 итерации.
|
||||
for i := 0; i < 30; i++ {
|
||||
time.Sleep(1 * time.Second)
|
||||
checkErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing)
|
||||
// NotFound = finalizer снят, объект исчез из etcd — можно создавать.
|
||||
if errors.IsNotFound(checkErr) {
|
||||
deleted = true
|
||||
break
|
||||
}
|
||||
// Любая другая ошибка (timeout, сбой API) — продолжаем ждать.
|
||||
}
|
||||
// Сбрасываем ResourceVersion — при split-brain etcd считает объект новым
|
||||
|
||||
// Таймаут: объект не исчез за 30 секунд.
|
||||
// Клиент (terraform) получит 409 и должен сделать retry позже.
|
||||
if !deleted {
|
||||
writeJSON(w, http.StatusConflict, errResp("function is being deleted, try again later"))
|
||||
return
|
||||
}
|
||||
|
||||
// Объект исчез — сбрасываем ResourceVersion и создаём как новый.
|
||||
fn.ResourceVersion = ""
|
||||
if createErr := h.K8s.Create(r.Context(), fn); createErr != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errResp(createErr.Error()))
|
||||
@@ -152,6 +191,29 @@ func (h *Handler) CreateFunction(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, fnToResponse(fn))
|
||||
return
|
||||
}
|
||||
|
||||
// --- Сценарии B и C: split-brain или Failed ---
|
||||
// NotFound при Get = кеш врёт (B); Failed фаза = сломанный build (C).
|
||||
shouldRecreate := errors.IsNotFound(getErr) ||
|
||||
(getErr == nil && existing.Status.Phase == slessv1alpha1.FunctionPhaseFailed)
|
||||
if shouldRecreate {
|
||||
// Если объект реально есть (Failed) — сначала удаляем.
|
||||
// Ошибку Delete игнорируем: Create покажет ошибку сам если что-то пошло не так.
|
||||
if getErr == nil {
|
||||
_ = h.K8s.Delete(r.Context(), existing)
|
||||
}
|
||||
// Сбрасываем ResourceVersion — при split-brain etcd считает объект новым.
|
||||
fn.ResourceVersion = ""
|
||||
if createErr := h.K8s.Create(r.Context(), fn); createErr != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errResp(createErr.Error()))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, fnToResponse(fn))
|
||||
return
|
||||
}
|
||||
|
||||
// Объект живой (Ready/Building), DeletionTimestamp=zero.
|
||||
// Легитимный конфликт — клиент пытается создать дубликат.
|
||||
writeJSON(w, http.StatusConflict, errResp("function already exists"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Изменено: 2026-03-20 (merge: FunctionJob теперь самодостаточен — убран FunctionRef, добавлены Runtime/Entrypoint/Env)
|
||||
// Изменено: 2026-03-21 (fix: DeleteJob возвращает 404 вместо 204 при отсутствующем объекте)
|
||||
// Изменено: 2026-03-22 (fix: UploadJobCode retry loop против cache lag controller-runtime)
|
||||
// jobs.go — CRUD handlers для FunctionJob CRD.
|
||||
// Создаёт/читает/удаляет k8s FunctionJob ресурсы.
|
||||
// Namespace берётся из URL: /v1/namespaces/{namespace}/jobs/{name}
|
||||
@@ -7,7 +8,9 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -185,14 +188,24 @@ func (h *Handler) UploadJobCode(w http.ResponseWriter, r *http.Request) {
|
||||
ns := namespace(r)
|
||||
name := pathVar(r, "name")
|
||||
|
||||
// Читаем FunctionJob для получения runtime
|
||||
// Читаем FunctionJob для получения runtime.
|
||||
// Retry до 5 раз с задержкой 200мс — защита от cache lag controller-runtime:
|
||||
// сразу после POST /jobs (201) informer cache может ещё не синхронизировать новый CR.
|
||||
fj := &slessv1alpha1.FunctionJob{}
|
||||
if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, fj); err != nil {
|
||||
if errors.IsNotFound(err) {
|
||||
var getJobErr error
|
||||
for i := 0; i < 5; i++ {
|
||||
getJobErr = h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, fj)
|
||||
if getJobErr == nil || !errors.IsNotFound(getJobErr) {
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
if getJobErr != nil {
|
||||
if errors.IsNotFound(getJobErr) {
|
||||
writeJSON(w, http.StatusNotFound, errResp("job not found"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
|
||||
writeJSON(w, http.StatusInternalServerError, errResp(getJobErr.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -221,8 +234,10 @@ func (h *Handler) UploadJobCode(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Версия на основе timestamp — каждый upload → новый уникальный ключ в S3
|
||||
version := time.Now().Format("20060102150405")
|
||||
// Версия = sha256(загруженный zip). Одинаковый код → одинаковый s3Key → cache hit.
|
||||
// Изменился код → новый хеш → новый build.
|
||||
zipHash := sha256.Sum256(zipData)
|
||||
version := fmt.Sprintf("%x", zipHash[:])[:16]
|
||||
s3Key, err := h.S3.UploadContext(r.Context(), ns, name, version, buf, int64(buf.Len()))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errResp("upload to S3: "+err.Error()))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Создано: 2026-03-20 (function-service-split)
|
||||
// Изменено: 2026-03-21 (fix: DeleteService возвращает 404 вместо 204 при отсутствующем объекте)
|
||||
// Изменено: 2026-03-22 (fix: CreateService 409 при пересоздании сервиса через terraform -replace)
|
||||
// Изменено: 2026-03-23 (fix: UploadServiceCode retry при 404 из-за cache lag controller-runtime)
|
||||
// services.go — CRUD handlers для Service CRD (sless_service).
|
||||
// sless_service = long-running Deployment + URL. Каждый вызов проксируется к поду.
|
||||
// Namespace берётся из URL: /v1/namespaces/{namespace}/services/{name}
|
||||
@@ -7,7 +9,9 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -134,18 +138,69 @@ func (h *Handler) CreateService(w http.ResponseWriter, r *http.Request) {
|
||||
S3Key: req.S3Key,
|
||||
},
|
||||
}
|
||||
// Пытаемся создать Service CRD в k8s.
|
||||
// h.K8s.Create обращается к etcd через controller-runtime client.
|
||||
if err := h.K8s.Create(r.Context(), svc); err != nil {
|
||||
|
||||
// IsAlreadyExists = etcd вернул 409 CONFLICT.
|
||||
// Это нормально при конкурентных запросах или при "replace" через terraform.
|
||||
// Сценарии:
|
||||
//
|
||||
// [A] terraform apply -replace:
|
||||
// 1. terraform DELETE /services/{name} → API вызывает h.K8s.Delete(svc)
|
||||
// 2. k8s НЕ удаляет объект немедленно: ставит DeletionTimestamp и ждёт
|
||||
// пока service_controller.go снимет finalizer sless.kube5s.ru/service-finalizer
|
||||
// (контроллер сначала сносит Deployment/Service/Ingress)
|
||||
// 3. terraform сразу POST /services/{name} → h.K8s.Create → IsAlreadyExists
|
||||
// (объект ещё есть в etcd, просто помечен на удаление)
|
||||
// → СТАРЫЙ КОД: видел phase=Ready, shouldRecreate=false → возвращал 409 ← БАГ
|
||||
// → НОВЫЙ КОД: видит DeletionTimestamp → ждёт исчезновения → создаёт
|
||||
//
|
||||
// [B] Кеш controller-runtime (split-brain):
|
||||
// Объект удалён из etcd, но informer-кеш ещё не обновился.
|
||||
// h.K8s.Create падает с IsAlreadyExists из кеша, хотя в etcd объекта нет.
|
||||
// → Get возвращает NotFound → пересоздаём.
|
||||
//
|
||||
// [C] Сервис в фазе Failed:
|
||||
// Предыдущий build провалился, terraform не добавил в state.
|
||||
// Новый apply пытается создать снова → Failed объект удаляем и пересоздаём.
|
||||
if errors.IsAlreadyExists(err) {
|
||||
// Повторяем логику function_handler: обрабатываем split-brain кеша.
|
||||
// Если объект реально есть и не в фазе Failed — возвращаем 409.
|
||||
|
||||
// Получаем актуальное состояние объекта напрямую из etcd (не из кеша).
|
||||
// Нужно чтобы точно определить сценарий A/B/C.
|
||||
existing := &slessv1alpha1.Service{}
|
||||
getErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing)
|
||||
shouldRecreate := errors.IsNotFound(getErr) ||
|
||||
(getErr == nil && existing.Status.Phase == slessv1alpha1.ServicePhaseFailed)
|
||||
if shouldRecreate {
|
||||
if getErr == nil {
|
||||
_ = h.K8s.Delete(r.Context(), existing)
|
||||
|
||||
// --- Сценарий A: объект помечен на удаление ---
|
||||
// DeletionTimestamp ≠ zero означает что k8s принял DELETE и ждёт finalizer.
|
||||
// Finalizer снимает service_controller.go асинхронно (обычно 1-5 сек).
|
||||
// Мы не можем создать объект пока старый существует — ждём его исчезновения.
|
||||
if getErr == nil && !existing.DeletionTimestamp.IsZero() {
|
||||
|
||||
deleted := false
|
||||
// Проверяем каждую секунду до 30 итераций (= 30 секунд максимум).
|
||||
// Обычно finalizer снимается за 1-3 секунды, 30 — запас на перегруженный кластер.
|
||||
for i := 0; i < 30; i++ {
|
||||
time.Sleep(1 * time.Second)
|
||||
checkErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing)
|
||||
// IsNotFound = объект полностью исчез из etcd — можно создавать.
|
||||
if errors.IsNotFound(checkErr) {
|
||||
deleted = true
|
||||
break
|
||||
}
|
||||
// Любая другая ошибка Get — продолжаем ждать (может быть временный сбой API).
|
||||
}
|
||||
|
||||
// 30 секунд прошло, объект всё ещё не удалён.
|
||||
// Вероятно контроллер завис или finalizer не снимается.
|
||||
// Возвращаем 409 с понятным сообщением — клиент (terraform) должен retry.
|
||||
if !deleted {
|
||||
writeJSON(w, http.StatusConflict, errResp("service is being deleted, try again later"))
|
||||
return
|
||||
}
|
||||
|
||||
// Объект исчез. Сбрасываем ResourceVersion чтобы k8s воспринял как новый объект.
|
||||
// ResourceVersion заполняется при первом Get — при Create должен быть пустым.
|
||||
svc.ResourceVersion = ""
|
||||
if createErr := h.K8s.Create(r.Context(), svc); createErr != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errResp(createErr.Error()))
|
||||
@@ -154,11 +209,39 @@ func (h *Handler) CreateService(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, svcToResponse(svc))
|
||||
return
|
||||
}
|
||||
|
||||
// --- Сценарии B и C: split-brain кеша или объект в фазе Failed ---
|
||||
// shouldRecreate=true если:
|
||||
// - getErr = NotFound (кеш врёт — объекта в etcd нет)
|
||||
// - объект есть, но в фазе Failed (предыдущий build провалился)
|
||||
shouldRecreate := errors.IsNotFound(getErr) ||
|
||||
(getErr == nil && existing.Status.Phase == slessv1alpha1.ServicePhaseFailed)
|
||||
if shouldRecreate {
|
||||
// Если объект реально существует (Failed) — удаляем его перед пересозданием.
|
||||
// Ошибку удаления игнорируем: даже если Delete упал,
|
||||
// следующий Create либо пройдёт (объект исчез) либо снова упадёт с понятной ошибкой.
|
||||
if getErr == nil {
|
||||
_ = h.K8s.Delete(r.Context(), existing)
|
||||
}
|
||||
// Сбрасываем ResourceVersion — при split-brain etcd считает объект новым.
|
||||
svc.ResourceVersion = ""
|
||||
if createErr := h.K8s.Create(r.Context(), svc); createErr != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errResp(createErr.Error()))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, svcToResponse(svc))
|
||||
return
|
||||
}
|
||||
|
||||
// Объект существует, DeletionTimestamp=zero, фаза не Failed.
|
||||
// Это легитимный конфликт — сервис реально работает, клиент пытается создать дубликат.
|
||||
writeJSON(w, http.StatusConflict, errResp("service already exists"))
|
||||
return
|
||||
}
|
||||
// k8s возвращает StatusInvalid (422) при нарушении enum-валидации CRD (например, неизвестный runtime)
|
||||
// — маппим это в 400, а не в 500
|
||||
|
||||
// k8s возвращает StatusInvalid (422) при нарушении enum-валидации CRD.
|
||||
// Например: runtime="java8" которого нет в openapi enum сервисного CRD.
|
||||
// Маппим в 400 (Bad Request) а не в 500 — это ошибка клиента, не сервера.
|
||||
if errors.IsInvalid(err) {
|
||||
writeJSON(w, http.StatusBadRequest, errResp("invalid service spec: "+err.Error()))
|
||||
return
|
||||
@@ -270,13 +353,24 @@ func (h *Handler) UploadServiceCode(w http.ResponseWriter, r *http.Request) {
|
||||
ns := namespace(r)
|
||||
name := pathVar(r, "name")
|
||||
|
||||
// Retry до 5 раз с задержкой 200мс — защита от cache lag controller-runtime.
|
||||
// Проблема: после POST /services (201) кеш informer может не успеть обновиться,
|
||||
// и Get возвращает IsNotFound в течение ~100-400мс после создания CR.
|
||||
svc := &slessv1alpha1.Service{}
|
||||
if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, svc); err != nil {
|
||||
if errors.IsNotFound(err) {
|
||||
var getErr error
|
||||
for i := 0; i < 5; i++ {
|
||||
getErr = h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, svc)
|
||||
if getErr == nil || !errors.IsNotFound(getErr) {
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
if getErr != nil {
|
||||
if errors.IsNotFound(getErr) {
|
||||
writeJSON(w, http.StatusNotFound, errResp("service not found"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
|
||||
writeJSON(w, http.StatusInternalServerError, errResp(getErr.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -303,7 +397,10 @@ func (h *Handler) UploadServiceCode(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
version := time.Now().Format("20060102150405")
|
||||
// Версия = sha256(загруженный zip). Одинаковый код → одинаковый s3Key → cache hit.
|
||||
// Изменился код → новый хеш → новый build.
|
||||
zipHash := sha256.Sum256(zipData)
|
||||
version := fmt.Sprintf("%x", zipHash[:])[:16]
|
||||
s3Key, err := h.S3.UploadContext(r.Context(), ns, name, version, buf, int64(buf.Len()))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errResp("upload to S3: "+err.Error()))
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
@@ -65,8 +66,10 @@ func (h *Handler) UploadCode(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Версия на основе timestamp — каждый upload → новый уникальный ключ в S3
|
||||
version := time.Now().Format("20060102150405")
|
||||
// Версия = sha256(загруженный zip). Одинаковый код → одинаковый s3Key → cache hit.
|
||||
// Изменился код → новый хеш → новый build.
|
||||
zipHash := sha256.Sum256(zipData)
|
||||
version := fmt.Sprintf("%x", zipHash[:])[:16]
|
||||
s3Key, err := h.S3.UploadContext(r.Context(), ns, name, version, buf, int64(buf.Len()))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errResp("upload to S3: "+err.Error()))
|
||||
|
||||
+164
-38
@@ -1,16 +1,22 @@
|
||||
// Изменено: 2026-03-11
|
||||
// Изменено: 2026-03-22
|
||||
// Builder — запускает kaniko Job в k8s для сборки Docker образа из кода функции.
|
||||
// Почему kaniko, а не docker-in-docker (DinD):
|
||||
// kaniko не требует privileged контейнер, что безопаснее в managed кластере.
|
||||
// kaniko читает контекст сборки из S3 напрямую.
|
||||
// Workflow: FunctionController вызывает Build → EnsureProject (Harbor) → Job запускается → образ пушится в registry.
|
||||
// ImageExists: перед сборкой проверяем наличие образа по тегу в registry.
|
||||
// Если образ с таким тегом (= hash кода) уже существует — сборка пропускается.
|
||||
// Это даёт идемпотентность destroy+apply: если код не менялся, deploy мгновенный.
|
||||
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
@@ -28,47 +34,55 @@ type Projecter interface {
|
||||
|
||||
// Builder — управляет сборкой Docker образов через kaniko Jobs в k8s.
|
||||
type Builder struct {
|
||||
client client.Client
|
||||
builderImage string // образ kaniko
|
||||
registryHost string // куда пушим образ (DockerHub: "naeel"; Harbor: "host")
|
||||
registryProject string // проект/org внутри registry (Harbor: project; DockerHub: пусто)
|
||||
registrySecret string // имя k8s Secret с docker-кредами для kaniko
|
||||
s3Endpoint string // откуда kaniko берёт код
|
||||
s3AccessKey string
|
||||
s3SecretKey string
|
||||
s3Bucket string
|
||||
namespace string // namespace где запускаем build Job'ы
|
||||
harborClient Projecter // nil если Harbor не используется
|
||||
client client.Client
|
||||
builderImage string // образ kaniko
|
||||
registryHost string // куда пушим образ (DockerHub: "naeel"; Harbor: "host")
|
||||
registryProject string // проект/org внутри registry (Harbor: project; DockerHub: пусто)
|
||||
registrySecret string // имя k8s Secret с docker-кредами для kaniko
|
||||
registryInsecure bool // true = in-cluster HTTP registry, без TLS и авторизации
|
||||
s3Endpoint string // откуда kaniko берёт код
|
||||
s3AccessKey string
|
||||
s3SecretKey string
|
||||
s3Bucket string
|
||||
namespace string // namespace где запускаем build Job'ы
|
||||
harborClient Projecter // nil если Harbor не используется
|
||||
}
|
||||
|
||||
// Config — параметры для создания Builder'а.
|
||||
type Config struct {
|
||||
BuilderImage string
|
||||
RegistryHost string
|
||||
RegistryProject string // пусто = DockerHub-режим (2 уровня); задан = project-режим (3 уровня)
|
||||
RegistrySecret string // имя k8s Secret с .dockerconfigjson для пуша образов
|
||||
S3Endpoint string
|
||||
S3AccessKey string
|
||||
S3SecretKey string
|
||||
S3Bucket string
|
||||
Namespace string
|
||||
HarborClient Projecter // nil — Harbor не используется, EnsureProject пропускается
|
||||
BuilderImage string
|
||||
RegistryHost string
|
||||
RegistryProject string // пусто = DockerHub-режим (2 уровня); задан = project-режим (3 уровня)
|
||||
RegistrySecret string // имя k8s Secret с .dockerconfigjson для пуша образов
|
||||
RegistryInsecure bool // true = HTTP registry (in-cluster), kaniko получает --insecure
|
||||
S3Endpoint string
|
||||
S3AccessKey string
|
||||
S3SecretKey string
|
||||
S3Bucket string
|
||||
Namespace string
|
||||
HarborClient Projecter // nil — Harbor не используется, EnsureProject пропускается
|
||||
}
|
||||
|
||||
// New создаёт новый Builder.
|
||||
func New(c client.Client, cfg Config) *Builder {
|
||||
// In-cluster HTTP registry не требует docker credentials — монтировать Secret не нужно.
|
||||
registrySecret := cfg.RegistrySecret
|
||||
if cfg.RegistryInsecure {
|
||||
registrySecret = ""
|
||||
}
|
||||
return &Builder{
|
||||
client: c,
|
||||
builderImage: cfg.BuilderImage,
|
||||
registryHost: cfg.RegistryHost,
|
||||
registryProject: cfg.RegistryProject,
|
||||
registrySecret: cfg.RegistrySecret,
|
||||
s3Endpoint: cfg.S3Endpoint,
|
||||
s3AccessKey: cfg.S3AccessKey,
|
||||
s3SecretKey: cfg.S3SecretKey,
|
||||
s3Bucket: cfg.S3Bucket,
|
||||
namespace: cfg.Namespace,
|
||||
harborClient: cfg.HarborClient,
|
||||
client: c,
|
||||
builderImage: cfg.BuilderImage,
|
||||
registryHost: cfg.RegistryHost,
|
||||
registryProject: cfg.RegistryProject,
|
||||
registrySecret: registrySecret,
|
||||
registryInsecure: cfg.RegistryInsecure,
|
||||
s3Endpoint: cfg.S3Endpoint,
|
||||
s3AccessKey: cfg.S3AccessKey,
|
||||
s3SecretKey: cfg.S3SecretKey,
|
||||
s3Bucket: cfg.S3Bucket,
|
||||
namespace: cfg.Namespace,
|
||||
harborClient: cfg.HarborClient,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +117,108 @@ func (b *Builder) ImageRef(namespace, funcName, s3Key string) string {
|
||||
return fmt.Sprintf("%s/%s-%s:%s", b.registryHost, nsPrefix, funcName, tag)
|
||||
}
|
||||
|
||||
// ImageExists проверяет наличие образа в Docker Registry v2 по тегу (без pull).
|
||||
// Возвращает (true, nil) если образ есть, (false, nil) если нет,
|
||||
// (false, err) если registry недоступен — контроллер должен requeue, не запускать сборку.
|
||||
//
|
||||
// Два режима:
|
||||
// - insecure (in-cluster registry:2): HTTP, без TLS, без авторизации
|
||||
// - secure (DockerHub): HTTPS, анонимный bearer-token для публичных репо
|
||||
func (b *Builder) ImageExists(ctx context.Context, imageRef string) (bool, error) {
|
||||
colonIdx := strings.LastIndex(imageRef, ":")
|
||||
if colonIdx < 0 {
|
||||
return false, nil
|
||||
}
|
||||
repoFull := imageRef[:colonIdx]
|
||||
tag := imageRef[colonIdx+1:]
|
||||
|
||||
if b.registryInsecure {
|
||||
// In-cluster HTTP registry — без авторизации и TLS.
|
||||
// repoFull вида "host:port/path" → берём host до первого /
|
||||
slashIdx := strings.Index(repoFull, "/")
|
||||
if slashIdx < 0 {
|
||||
return false, nil
|
||||
}
|
||||
registryHost := repoFull[:slashIdx]
|
||||
repo := repoFull[slashIdx+1:]
|
||||
|
||||
httpClient := &http.Client{Timeout: 5 * time.Second}
|
||||
manifestURL := fmt.Sprintf("http://%s/v2/%s/manifests/%s", registryHost, repo, tag)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, manifestURL, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json")
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
// Registry недоступен — не притворяемся что образа нет, requeue
|
||||
return false, fmt.Errorf("registry unavailable at %s: %w", registryHost, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
return true, nil
|
||||
case http.StatusNotFound:
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("unexpected registry response: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Secure registry (DockerHub или HTTPS).
|
||||
// DockerHub: "naeel/sless-ff-pg" → registry = index.docker.io
|
||||
// Приватный: "harbor.host/proj/func" → registry = harbor.host
|
||||
registryHost := "index.docker.io"
|
||||
repo := repoFull
|
||||
if parts := strings.SplitN(repoFull, "/", 3); len(parts) == 3 {
|
||||
registryHost = parts[0]
|
||||
repo = parts[1] + "/" + parts[2]
|
||||
}
|
||||
|
||||
tokenURL := fmt.Sprintf("https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull", repo)
|
||||
if registryHost != "index.docker.io" {
|
||||
tokenURL = ""
|
||||
}
|
||||
|
||||
httpClient := &http.Client{Timeout: 5 * time.Second}
|
||||
var bearerToken string
|
||||
if tokenURL != "" {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
return false, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var tkResp struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tkResp); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
bearerToken = tkResp.Token
|
||||
}
|
||||
|
||||
manifestURL := fmt.Sprintf("https://%s/v2/%s/manifests/%s", registryHost, repo, tag)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, manifestURL, nil)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json")
|
||||
if bearerToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearerToken)
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return resp.StatusCode == http.StatusOK, nil
|
||||
}
|
||||
|
||||
// Build запускает kaniko Job для сборки образа функции.
|
||||
// Возвращает имя Job'а чтобы контроллер мог следить за его статусом.
|
||||
func (b *Builder) Build(ctx context.Context, namespace, funcName, s3Key string) (string, error) {
|
||||
@@ -142,11 +258,7 @@ func (b *Builder) Build(ctx context.Context, namespace, funcName, s3Key string)
|
||||
{
|
||||
Name: "kaniko",
|
||||
Image: b.builderImage,
|
||||
Args: []string{
|
||||
"--context=" + s3ContextURL,
|
||||
"--destination=" + imageRef,
|
||||
// --no-cache не поддерживается этой версией kaniko; кэш отключён по умолчанию
|
||||
},
|
||||
Args: b.kanikoArgs(s3ContextURL, imageRef),
|
||||
Env: []corev1.EnvVar{
|
||||
{Name: "AWS_ACCESS_KEY_ID", Value: b.s3AccessKey},
|
||||
{Name: "AWS_SECRET_ACCESS_KEY", Value: b.s3SecretKey},
|
||||
@@ -210,6 +322,20 @@ func (b *Builder) Cleanup(ctx context.Context, jobName string) error {
|
||||
return b.client.Delete(ctx, job, &client.DeleteOptions{PropagationPolicy: &propagation})
|
||||
}
|
||||
|
||||
// kanikoArgs строит аргументы для kaniko контейнера.
|
||||
// Добавляет --insecure если registry работает по HTTP (in-cluster).
|
||||
func (b *Builder) kanikoArgs(s3ContextURL, imageRef string) []string {
|
||||
args := []string{
|
||||
"--context=" + s3ContextURL,
|
||||
"--destination=" + imageRef,
|
||||
}
|
||||
if b.registryInsecure {
|
||||
// --insecure: push в HTTP registry без TLS (in-cluster registry:2)
|
||||
args = append(args, "--insecure")
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// dockerConfigVolumes возвращает Volume с docker-кредами если RegistrySecret задан.
|
||||
// Ключ .dockerconfigjson монтируется как config.json — стандартное имя для kaniko.
|
||||
func (b *Builder) dockerConfigVolumes() []corev1.Volume {
|
||||
|
||||
@@ -93,23 +93,30 @@ func generateDockerfile(runtime string, hasRequirements bool, hasPackageJSON boo
|
||||
// Go: multi-stage build с go.work (v0.1.3+).
|
||||
// base-образ содержит: /app/server/ (module sless/fn/server, package main).
|
||||
// kaniko копирует пользовательский код в /app/handler/.
|
||||
// Генерируем go.mod для handler если его нет (module sless/fn/handler).
|
||||
// Генерируем go.work: use ./server + use ./handler + replace sless/fn/handler => ./handler.
|
||||
// replace позволяет server.go импортировать sless/fn/handler независимо от имени модуля пользователя.
|
||||
// GOFLAGS=-mod=mod: позволяет go build подтягивать зависимости из handler/go.mod через GOPROXY.
|
||||
// Модуль handler всегда переименовывается в sless/fn/handler — это позволяет server.go
|
||||
// импортировать его по фиксированному пути без replace-директивы.
|
||||
// Почему нет replace: Go 1.23 запрещает replace для модуля, который одновременно в workspace
|
||||
// (use ./handler). Ошибка: "workspace module sless/fn/handler is replaced at all versions".
|
||||
// Решение: rename module → use workspace → require без replace.
|
||||
goModStep := ""
|
||||
if !hasGoMod {
|
||||
// Пользователь не предоставил go.mod — генерируем минимальный
|
||||
// Пользователь не предоставил go.mod — генерируем минимальный с правильным именем.
|
||||
goModStep = "RUN printf 'module sless/fn/handler\\n\\ngo 1.23\\n' > /app/handler/go.mod\n"
|
||||
} else {
|
||||
// Пользователь предоставил go.mod с произвольным именем модуля — переименовываем.
|
||||
goModStep = "RUN sed -i 's/^module .*/module sless\\/fn\\/handler/' /app/handler/go.mod\n"
|
||||
}
|
||||
content := fmt.Sprintf(
|
||||
"FROM %s AS builder\n"+
|
||||
"WORKDIR /app\n"+
|
||||
"COPY . /app/handler/\n"+
|
||||
goModStep+
|
||||
"RUN printf 'go 1.23\\n\\nuse ./server\\nuse ./handler\\n\\nreplace sless/fn/handler => ./handler\\n' > /app/go.work\n"+ // server/go.mod не содержит require sless/fn/handler — Go требует явного require даже при replace в go.work.
|
||||
// Патчим server/go.mod в момент kaniko-сборки (не меняет базовый образ).
|
||||
"RUN printf '\\nrequire sless/fn/handler v0.0.0\\n' >> /app/server/go.mod\n"+"RUN CGO_ENABLED=0 go build -o /server ./server\n"+
|
||||
// go.work: workspace из двух модулей без replace-директив.
|
||||
"RUN printf 'go 1.23\\n\\nuse ./server\\nuse ./handler\\n' > /app/go.work\n"+
|
||||
// Патчим server/go.mod в момент kaniko-сборки — добавляем require на handler.
|
||||
// Не меняет базовый образ (выполняется на этапе сборки пользовательского образа).
|
||||
"RUN printf '\\nrequire sless/fn/handler v0.0.0\\n' >> /app/server/go.mod\n"+
|
||||
"RUN CGO_ENABLED=0 go build -o /server ./server\n"+
|
||||
"FROM alpine:3.20\n"+
|
||||
"COPY --from=builder /server /server\n"+
|
||||
"EXPOSE 8080\n"+
|
||||
@@ -125,11 +132,17 @@ func generateDockerfile(runtime string, hasRequirements bool, hasPackageJSON boo
|
||||
if hasRequirements {
|
||||
content += "RUN pip install --no-cache-dir -r /app/function/requirements.txt\n"
|
||||
}
|
||||
// Проверяем синтаксис всех .py файлов на этапе сборки образа.
|
||||
// Если в коде синтаксическая ошибка — kaniko завершится с ошибкой и фаза = Failed.
|
||||
// Без этого шага образ собирается успешно, а под падает в CrashLoopBackOff при запуске.
|
||||
content += "RUN python -m py_compile $(find /app/function -name '*.py')\n"
|
||||
case "nodejs20":
|
||||
if hasPackageJSON {
|
||||
// cd нужен т.к. npm install читает package.json из текущей директории
|
||||
content += "RUN cd /app/function && npm install --omit=dev\n"
|
||||
}
|
||||
// Проверяем синтаксис всех .js файлов через node --check.
|
||||
content += "RUN find /app/function -name '*.js' | xargs -r node --check\n"
|
||||
}
|
||||
return []byte(content), nil
|
||||
}
|
||||
|
||||
@@ -40,6 +40,11 @@ type Config struct {
|
||||
// Создаётся через hack/create-registry-secret.sh
|
||||
RegistrySecret string
|
||||
|
||||
// RegistryInsecure — использовать HTTP (без TLS) для registry.
|
||||
// Нужно для in-cluster registry:2 где нет сертификата.
|
||||
// Включается через REGISTRY_INSECURE=true.
|
||||
RegistryInsecure bool
|
||||
|
||||
// BuilderImage — образ для сборки функций (kaniko или buildah)
|
||||
BuilderImage string
|
||||
|
||||
@@ -128,6 +133,11 @@ func Load() (*Config, error) {
|
||||
cfg.RegistrySecret = "sless-registry-auth"
|
||||
}
|
||||
|
||||
// REGISTRY_INSECURE=true — использовать HTTP вместо HTTPS для registry (in-cluster registry:2)
|
||||
if os.Getenv("REGISTRY_INSECURE") == "true" {
|
||||
cfg.RegistryInsecure = true
|
||||
}
|
||||
|
||||
// Опциональные параметры с дефолтами
|
||||
if v := os.Getenv("BUILDER_IMAGE"); v != "" {
|
||||
cfg.BuilderImage = v
|
||||
|
||||
@@ -131,16 +131,17 @@ func main() {
|
||||
}
|
||||
|
||||
bldr := builder.New(mgr.GetClient(), builder.Config{
|
||||
BuilderImage: cfg.BuilderImage,
|
||||
RegistryHost: cfg.RegistryHost,
|
||||
RegistryProject: cfg.RegistryProject,
|
||||
RegistrySecret: cfg.RegistrySecret,
|
||||
S3Endpoint: cfg.S3Endpoint,
|
||||
S3AccessKey: cfg.S3AccessKey,
|
||||
S3SecretKey: cfg.S3SecretKey,
|
||||
S3Bucket: cfg.S3Bucket,
|
||||
Namespace: "sless",
|
||||
HarborClient: harborProjecter,
|
||||
BuilderImage: cfg.BuilderImage,
|
||||
RegistryHost: cfg.RegistryHost,
|
||||
RegistryProject: cfg.RegistryProject,
|
||||
RegistrySecret: cfg.RegistrySecret,
|
||||
RegistryInsecure: cfg.RegistryInsecure,
|
||||
S3Endpoint: cfg.S3Endpoint,
|
||||
S3AccessKey: cfg.S3AccessKey,
|
||||
S3SecretKey: cfg.S3SecretKey,
|
||||
S3Bucket: cfg.S3Bucket,
|
||||
Namespace: "sless",
|
||||
HarborClient: harborProjecter,
|
||||
})
|
||||
|
||||
if err = (&controllers.FunctionReconciler{
|
||||
|
||||
+14
-10
@@ -1,18 +1,22 @@
|
||||
# Изменено: 2026-03-19 — v0.1.1: добавлены go.sum + go mod download (pgx/v5 v5.7.2).
|
||||
# Изменено: 2026-03-23 — v0.1.4: fix go.work (убран replace, добавлен sed rename модуля).
|
||||
# Base builder image для Go 1.23 serverless функций.
|
||||
# Это BUILDER-образ: golang:1.23-alpine + server.go + pre-cached зависимости.
|
||||
# Это BUILDER-образ: golang:1.23-alpine + server/ + pre-cached зависимости.
|
||||
# go mod download кеширует pgx/v5 в /root/go/pkg/mod — kaniko не скачивает их при каждой сборке.
|
||||
#
|
||||
# kaniko генерирует Dockerfile для каждой функции:
|
||||
# FROM naeel/sless-runtime-go1.23:v0.1.1 AS builder
|
||||
# FROM naeel/sless-runtime-go1.23:v0.1.3 AS builder
|
||||
# WORKDIR /app
|
||||
# COPY . /app/handler/
|
||||
# RUN CGO_ENABLED=0 go build -o /server .
|
||||
# RUN [ -f /app/handler/go.mod ] && sed -i 's/^module .*/module sless\/fn\/handler/' || printf '...' > /app/handler/go.mod
|
||||
# RUN printf 'go 1.23\n\nuse ./server\nuse ./handler\n' > /app/go.work # БЕЗ replace!
|
||||
# RUN printf '\nrequire sless/fn/handler v0.0.0\n' >> /app/server/go.mod
|
||||
# RUN CGO_ENABLED=0 go build -o /server ./server
|
||||
# FROM alpine:3.20
|
||||
# COPY --from=builder /server /server → финальный образ
|
||||
# COPY --from=builder /server /server
|
||||
#
|
||||
# Почему не multi-stage здесь: base образ должен иметь Go компилятор и module cache.
|
||||
# Бинарник собирается kaniko, а не здесь.
|
||||
# Почему replace убран: Go 1.23 запрещает replace для модуля, который одновременно в workspace.
|
||||
# Ошибка: "workspace module sless/fn/handler is replaced at all versions in the go.work file".
|
||||
# Решение: handler всегда переименовывается в sless/fn/handler через sed — replace не нужен.
|
||||
|
||||
FROM golang:1.23-alpine
|
||||
|
||||
@@ -20,10 +24,10 @@ WORKDIR /app
|
||||
|
||||
# Копируем mod файлы первыми — отдельный кешируемый слой.
|
||||
# При изменении go.mod/go.sum слой инвалидируется и go mod download запускается заново.
|
||||
COPY go.mod go.sum /app/
|
||||
RUN go mod download
|
||||
COPY server/go.mod server/go.sum /app/server/
|
||||
RUN cd /app/server && go mod download
|
||||
|
||||
# server.go — HTTP-wrapper + job-runner (main package, импортирует sless/fn/handler).
|
||||
COPY server.go /app/server.go
|
||||
COPY server/server.go /app/server/server.go
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Изменено: 2026-03-22 — переименован в sless/fn/server для поддержки go.work + user go.mod
|
||||
module sless/fn/server
|
||||
|
||||
go 1.23
|
||||
|
||||
require github.com/jackc/pgx/v5 v5.7.2
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
|
||||
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,97 @@
|
||||
// Создано: 2026-03-11
|
||||
// Изменено: 2026-03-21 (v0.1.2) — panic recovery: при panic в Handle() возвращаем HTTP 500 вместо EOF.
|
||||
// HTTP-обёртка для serverless функций на Go 1.23.
|
||||
// Компилируется kaniko ВМЕСТЕ с пользовательским кодом (package handler).
|
||||
//
|
||||
// Интерфейс пользователя — файл handler.go:
|
||||
//
|
||||
// package handler
|
||||
//
|
||||
// func Handle(event map[string]interface{}) interface{} {
|
||||
// return map[string]interface{}{"hello": event["name"]}
|
||||
// }
|
||||
//
|
||||
// SLESS_MODE=job: разово вызвать Handle(event) → вывести JSON → выйти (для FunctionJob).
|
||||
// По умолчанию: HTTP-сервер, каждый запрос → Handle(event) → JSON ответ.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"sless/fn/handler"
|
||||
)
|
||||
|
||||
func main() {
|
||||
mode := os.Getenv("SLESS_MODE")
|
||||
|
||||
// job-runner: разово вызвать Handle(event), вывести результат в JSON и выйти.
|
||||
if mode == "job" {
|
||||
eventJSON := os.Getenv("SLESS_EVENT")
|
||||
if eventJSON == "" {
|
||||
eventJSON = "{}"
|
||||
}
|
||||
var event map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(eventJSON), &event); err != nil {
|
||||
event = map[string]interface{}{}
|
||||
}
|
||||
result := handler.Handle(event)
|
||||
out, _ := json.Marshal(result)
|
||||
fmt.Println(string(out))
|
||||
return
|
||||
}
|
||||
|
||||
// HTTP-сервер
|
||||
port := "8080"
|
||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"status":"ok"}`)
|
||||
})
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Перехватываем panic в пользовательском коде — возвращаем 500 вместо EOF.
|
||||
// Без recover() Go закрывает соединение при панике → ingress видит EOF → 502.
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
log.Printf("panic recovered: %v", rec)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprintf(w, `{"error":"panic: %v"}`, rec)
|
||||
}
|
||||
}()
|
||||
event := map[string]interface{}{}
|
||||
if r.Method == http.MethodPost || r.Method == http.MethodPut {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err == nil && len(body) > 0 {
|
||||
_ = json.Unmarshal(body, &event)
|
||||
}
|
||||
}
|
||||
event["_path"] = r.URL.Path
|
||||
event["_method"] = r.Method
|
||||
if q := r.URL.Query(); len(q) > 0 {
|
||||
qmap := map[string]interface{}{}
|
||||
for k, v := range q {
|
||||
if len(v) == 1 {
|
||||
qmap[k] = v[0]
|
||||
} else {
|
||||
qmap[k] = v
|
||||
}
|
||||
}
|
||||
event["_query"] = qmap
|
||||
}
|
||||
result := handler.Handle(event)
|
||||
out, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"marshal failed"}`, 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(out)
|
||||
})
|
||||
log.Printf("sless runtime (go1.23) listening on :%s", port)
|
||||
log.Fatal(http.ListenAndServe(":"+port, nil))
|
||||
}
|
||||
@@ -27,7 +27,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: funcs
|
||||
image: naeel/sless-funcs-service:v0.1.3
|
||||
image: naeel/sless-funcs-service:v0.1.11
|
||||
ports:
|
||||
- containerPort: 8090
|
||||
env:
|
||||
|
||||
+644
-24
@@ -188,15 +188,25 @@
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.fn-url-row {
|
||||
padding: 0 14px 9px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fn-url-row:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fn-url {
|
||||
font-size: 0.76rem;
|
||||
color: #58a6ff;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 280px;
|
||||
flex-shrink: 1;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.fn-url:hover {
|
||||
@@ -312,6 +322,197 @@
|
||||
color: #484f58;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Кнопки действий на карточке функции */
|
||||
.action-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 3px 10px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #30363d;
|
||||
cursor: pointer;
|
||||
font-size: 0.73rem;
|
||||
font-weight: 500;
|
||||
background: #21262d;
|
||||
color: #8b949e;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
.action-btn:hover { background: #30363d; color: #c9d1d9; }
|
||||
.action-btn.del:hover { background: #3d1a1a; border-color: #6b1a1a; color: #f85149; }
|
||||
|
||||
/* Кодовый редактор во встроенном режиме */
|
||||
.edit-area {
|
||||
width: 100%;
|
||||
min-height: 220px;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
border: none;
|
||||
border-top: 1px solid #30363d;
|
||||
padding: 14px 16px;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: #161b22;
|
||||
border-top: 1px solid #30363d;
|
||||
}
|
||||
.edit-save-btn {
|
||||
padding: 5px 16px;
|
||||
background: #238636;
|
||||
border: 1px solid #2ea043;
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.edit-save-btn:hover { background: #2ea043; }
|
||||
.edit-save-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.edit-cancel-btn {
|
||||
padding: 5px 16px;
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
color: #c9d1d9;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.edit-status {
|
||||
font-size: 0.77rem;
|
||||
padding: 5px 8px;
|
||||
align-self: center;
|
||||
color: #8b949e;
|
||||
}
|
||||
.edit-status.ok { color: #3fb950; }
|
||||
.edit-status.err { color: #f85149; }
|
||||
|
||||
/* ---------- Модалка создания функции ---------- */
|
||||
.modal-backdrop {
|
||||
display: none;
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.65);
|
||||
z-index: 100;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-backdrop.open { display: flex; }
|
||||
.modal {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 10px;
|
||||
width: min(660px, 95vw);
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.modal h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #e6edf3;
|
||||
}
|
||||
.modal label {
|
||||
font-size: 0.82rem;
|
||||
color: #8b949e;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.modal input, .modal select {
|
||||
width: 100%;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #e6edf3;
|
||||
padding: 7px 10px;
|
||||
font-size: 0.85rem;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
.modal input:focus, .modal select:focus { border-color: #58a6ff; }
|
||||
.modal-row { display: flex; gap: 12px; }
|
||||
.modal-row > div { flex: 1; }
|
||||
.modal-code {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #e6edf3;
|
||||
padding: 10px 12px;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.modal-code:focus { border-color: #58a6ff; }
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.modal-create-btn {
|
||||
padding: 7px 20px;
|
||||
background: #238636;
|
||||
border: 1px solid #2ea043;
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.modal-create-btn:hover { background: #2ea043; }
|
||||
.modal-create-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.modal-cancel-btn {
|
||||
padding: 7px 20px;
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
color: #c9d1d9;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.modal-err {
|
||||
color: #f85149;
|
||||
font-size: 0.8rem;
|
||||
min-height: 18px;
|
||||
}
|
||||
.create-fn-btn {
|
||||
margin-left: auto;
|
||||
background: #238636;
|
||||
border: 1px solid #2ea043;
|
||||
color: #fff;
|
||||
padding: 6px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.create-fn-btn:hover { background: #2ea043; }
|
||||
|
||||
/* Таймер Building */
|
||||
.fn-timer {
|
||||
font-size: 0.72rem;
|
||||
color: #d29922;
|
||||
font-family: monospace;
|
||||
margin-left: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.badge-Building {
|
||||
animation: pulse-bld 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse-bld {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -321,12 +522,56 @@
|
||||
<span class="logo-slash">/</span>
|
||||
<span class="ns-badge" id="ns-label">…</span>
|
||||
<button class="refresh-btn" onclick="location.reload()">↻ обновить</button>
|
||||
<button class="create-fn-btn" id="open-create-modal">+ Создать функцию</button>
|
||||
</header>
|
||||
<main>
|
||||
<p class="summary" id="summary"></p>
|
||||
<div id="fn-list"></div>
|
||||
</main>
|
||||
|
||||
<!-- Модалка создания новой функции -->
|
||||
<div class="modal-backdrop" id="create-modal">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||
<h2 id="modal-title">Создать функцию</h2>
|
||||
|
||||
<div>
|
||||
<label>Runtime</label>
|
||||
<select id="m-runtime">
|
||||
<option value="python3.11">Python 3.11</option>
|
||||
<option value="nodejs20">Node.js 20</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="modal-row">
|
||||
<div>
|
||||
<label>Имя функции (k8s name)</label>
|
||||
<input id="m-name" type="text" placeholder="my-hello-fn" pattern="[a-z0-9][a-z0-9\-]{0,61}[a-z0-9]">
|
||||
</div>
|
||||
<div>
|
||||
<label>Entrypoint (module.func)</label>
|
||||
<input id="m-entrypoint" type="text" value="handler.handler">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label id="m-filename-label">Файл: <code id="m-filename-display">handler.py</code></label>
|
||||
<textarea class="modal-code" id="m-code" spellcheck="false"></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label><span id="m-deps-name">requirements.txt</span> <span style="color:#484f58;font-size:0.75rem">(зависимости, опционально)</span></label>
|
||||
<textarea class="modal-code" id="m-deps" spellcheck="false" style="min-height:80px"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="modal-err" id="m-err"></div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="modal-cancel-btn" id="modal-cancel">Отмена</button>
|
||||
<button class="modal-create-btn" id="modal-submit">Создать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script id="fndata" type="application/json">__PAGE_DATA__</script>
|
||||
<script>
|
||||
(function () {
|
||||
@@ -355,6 +600,8 @@
|
||||
list.appendChild(buildCard(fns[i], byFn[fns[i].name] || []));
|
||||
}
|
||||
|
||||
// ---------- Карточки функций ----------
|
||||
|
||||
function buildCard(fn, triggers) {
|
||||
var card = $el('div', 'fn-card');
|
||||
var hdr = $el('div', 'fn-header');
|
||||
@@ -363,40 +610,46 @@
|
||||
|
||||
$txt(hdr, 'span', 'fn-name', fn.name);
|
||||
$txt(hdr, 'span', 'badge badge-runtime', fn.runtime);
|
||||
$txt(hdr, 'span', 'badge badge-' + fn.phase, fn.phase || 'Pending');
|
||||
var phaseBadge = $txt(hdr, 'span', 'badge badge-' + fn.phase, fn.phase || 'Pending');
|
||||
if (fn.kind) {
|
||||
var kindLabel = fn.kind === 'service' ? 'always-on' : 'job';
|
||||
$txt(hdr, 'span', 'badge badge-kind-' + fn.kind, kindLabel);
|
||||
}
|
||||
|
||||
// sless_service имеет прямой URL из статуса деплоя
|
||||
if (fn.url) {
|
||||
// Для Building/Pending — таймер секунд + автополинг до Ready/Failed
|
||||
if (fn.phase === 'Building' || fn.phase === 'Pending') {
|
||||
var timerEl = $txt(hdr, 'span', 'fn-timer', '0с');
|
||||
startBuildPoll(phaseBadge, timerEl, fn, ns, null, function(url) {
|
||||
if (!fn.url) { fn.url = url; setCardUrl(url); }
|
||||
});
|
||||
}
|
||||
|
||||
if (fn.message) $txt(hdr, 'span', 'fn-msg', fn.message);
|
||||
hdr.appendChild($el('span', 'spacer'));
|
||||
|
||||
// urlRow — отдельная строка под заголовком для URL
|
||||
var urlRow = $el('div', 'fn-url-row');
|
||||
var cardUrlAnchor = null;
|
||||
|
||||
function setCardUrl(url) {
|
||||
if (cardUrlAnchor) return; // уже есть
|
||||
var a = document.createElement('a');
|
||||
a.className = 'fn-url';
|
||||
a.href = fn.url;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
a.textContent = fn.url;
|
||||
a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
|
||||
a.textContent = url;
|
||||
a.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||
hdr.appendChild(a);
|
||||
urlRow.appendChild(a);
|
||||
cardUrlAnchor = a;
|
||||
}
|
||||
|
||||
if (fn.url) setCardUrl(fn.url);
|
||||
|
||||
for (var t = 0; t < triggers.length; t++) {
|
||||
var tr = triggers[t];
|
||||
if (tr.type === 'http') {
|
||||
// Для job-style функций URL берём из триггера; для сервисов уже показан fn.url
|
||||
if (!fn.url) {
|
||||
var url = extURL ? extURL + '/fn/' + ns + '/' + fn.name : (tr.url || '');
|
||||
if (url) {
|
||||
var a = document.createElement('a');
|
||||
a.className = 'fn-url';
|
||||
a.href = url;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
a.textContent = url;
|
||||
a.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||
hdr.appendChild(a);
|
||||
}
|
||||
if (url) setCardUrl(url);
|
||||
}
|
||||
hdr.appendChild(makeTriggerBtn(tr, ns));
|
||||
} else if (tr.type === 'cron') {
|
||||
@@ -405,8 +658,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
if (fn.message) $txt(hdr, 'span', 'fn-msg', fn.message);
|
||||
hdr.appendChild($el('span', 'spacer'));
|
||||
// Кнопка редактирования кода
|
||||
var editBtn = $el('button', 'action-btn');
|
||||
editBtn.textContent = '✎';
|
||||
editBtn.title = 'Редактировать код';
|
||||
editBtn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
body.innerHTML = ''; // сбрасываем содержимое перед переходом в edit
|
||||
loaded = false; // следующий expand снова зачитает исходники
|
||||
body.style.display = 'block';
|
||||
icon.style.transform = 'rotate(90deg)';
|
||||
openEditMode(body, ns, fn.name, fn.kind, fn.runtime);
|
||||
});
|
||||
hdr.appendChild(editBtn);
|
||||
|
||||
// Кнопка удаления
|
||||
var delBtn = $el('button', 'action-btn del');
|
||||
delBtn.textContent = '✕';
|
||||
delBtn.title = 'Удалить функцию';
|
||||
delBtn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (!confirm('Удалить функцию «' + fn.name + '»?\n\nЭто действие необратимо.')) return;
|
||||
delBtn.disabled = true;
|
||||
fetch('/funcs/' + ns + '/delete-function/' + fn.name + '?kind=' + (fn.kind || 'function'), {
|
||||
method: 'DELETE'
|
||||
}).then(function (r) {
|
||||
if (r.ok) {
|
||||
card.remove();
|
||||
} else {
|
||||
return r.text().then(function (t) { alert('Ошибка удаления: ' + t); });
|
||||
}
|
||||
}).catch(function (err) {
|
||||
alert('Сетевая ошибка: ' + err);
|
||||
}).finally(function () {
|
||||
delBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
hdr.appendChild(delBtn);
|
||||
|
||||
var icon = $txt(hdr, 'span', 'expand-icon', '▶');
|
||||
|
||||
var loaded = false;
|
||||
@@ -425,10 +714,226 @@
|
||||
});
|
||||
|
||||
card.appendChild(hdr);
|
||||
card.appendChild(urlRow);
|
||||
card.appendChild(body);
|
||||
return card;
|
||||
}
|
||||
|
||||
// openEditMode — открывает тело карточки в режиме редактирования.
|
||||
// Загружает текущий код и файл зависимостей (requirements.txt / package.json).
|
||||
function openEditMode(body, ns, fnName, fnKind, fnRuntime) {
|
||||
body.innerHTML = '';
|
||||
var msg = $el('div', 'src-msg');
|
||||
msg.textContent = 'Загрузка кода…';
|
||||
body.appendChild(msg);
|
||||
|
||||
var kindParam = fnKind === 'service' ? '?kind=service' : '';
|
||||
fetch('/funcs/' + ns + '/source/' + fnName + kindParam)
|
||||
.then(function (r) {
|
||||
body.removeChild(msg);
|
||||
if (!r.ok) {
|
||||
var e = $el('div', 'src-msg');
|
||||
e.textContent = 'Ошибка загрузки кода: ' + r.status;
|
||||
body.appendChild(e);
|
||||
return;
|
||||
}
|
||||
return r.json().then(function (files) {
|
||||
var editable = files.filter(function (f) {
|
||||
return !f.binary && (f.name.endsWith('.py') || f.name.endsWith('.js'));
|
||||
});
|
||||
if (editable.length === 0) {
|
||||
editable = [{ name: defaultFilename(fnRuntime), content: helloWorldTemplate(fnRuntime) }];
|
||||
}
|
||||
var mainFile = editable[0];
|
||||
|
||||
// Ищем файл зависимостей (requirements.txt / package.json)
|
||||
var tpl = TEMPLATES[fnRuntime] || {};
|
||||
var depsFilename = tpl.depsFilename || '';
|
||||
var depsContent = tpl.deps || '';
|
||||
if (depsFilename) {
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
if (files[i].name === depsFilename && !files[i].binary) {
|
||||
depsContent = files[i].content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderEditArea(body, ns, fnName, fnKind, fnRuntime,
|
||||
mainFile.name, mainFile.content, depsFilename, depsContent);
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
body.innerHTML = '';
|
||||
var e = $el('div', 'src-msg');
|
||||
e.textContent = 'Ошибка: ' + err;
|
||||
body.appendChild(e);
|
||||
});
|
||||
}
|
||||
|
||||
// renderEditArea — rename input + code textarea + deps textarea + Save/Cancel.
|
||||
function renderEditArea(body, ns, fnName, fnKind, fnRuntime, filename, initialCode, depsFilename, depsContent) {
|
||||
body.innerHTML = '';
|
||||
|
||||
// Строка переименования
|
||||
var renameRow = $el('div', 'edit-actions');
|
||||
renameRow.style.borderBottom = '1px solid #30363d';
|
||||
var nameLabel = $el('span', 'edit-status');
|
||||
nameLabel.textContent = 'Имя:';
|
||||
var nameInput = document.createElement('input');
|
||||
nameInput.type = 'text';
|
||||
nameInput.value = fnName;
|
||||
nameInput.style.cssText = 'flex:1;max-width:240px;background:#0d1117;border:1px solid #30363d;border-radius:5px;color:#e6edf3;padding:4px 8px;font-size:0.8rem;outline:none;';
|
||||
var renameBtn = $el('button', 'edit-cancel-btn');
|
||||
renameBtn.textContent = 'Переименовать';
|
||||
var renameStatus = $el('span', 'edit-status');
|
||||
renameRow.appendChild(nameLabel);
|
||||
renameRow.appendChild(nameInput);
|
||||
renameRow.appendChild(renameBtn);
|
||||
renameRow.appendChild(renameStatus);
|
||||
body.appendChild(renameRow);
|
||||
|
||||
// Код функции
|
||||
var codeHdr = $el('div', 'file-header');
|
||||
codeHdr.textContent = filename;
|
||||
body.appendChild(codeHdr);
|
||||
var ta = document.createElement('textarea');
|
||||
ta.className = 'edit-area';
|
||||
ta.spellcheck = false;
|
||||
ta.value = initialCode;
|
||||
body.appendChild(ta);
|
||||
|
||||
// Файл зависимостей (если есть)
|
||||
var depsTA = null;
|
||||
if (depsFilename) {
|
||||
var depsHdr = $el('div', 'file-header');
|
||||
depsHdr.textContent = depsFilename;
|
||||
body.appendChild(depsHdr);
|
||||
depsTA = document.createElement('textarea');
|
||||
depsTA.className = 'edit-area';
|
||||
depsTA.style.minHeight = '80px';
|
||||
depsTA.spellcheck = false;
|
||||
depsTA.value = depsContent;
|
||||
body.appendChild(depsTA);
|
||||
}
|
||||
|
||||
// Кнопки Save / Cancel
|
||||
var actions = $el('div', 'edit-actions');
|
||||
var saveBtn = $el('button', 'edit-save-btn');
|
||||
saveBtn.textContent = 'Сохранить';
|
||||
var cancelBtn = $el('button', 'edit-cancel-btn');
|
||||
cancelBtn.textContent = 'Закрыть';
|
||||
var status = $el('span', 'edit-status');
|
||||
actions.appendChild(saveBtn);
|
||||
actions.appendChild(cancelBtn);
|
||||
actions.appendChild(status);
|
||||
body.appendChild(actions);
|
||||
|
||||
saveBtn.addEventListener('click', function () {
|
||||
saveBtn.disabled = true;
|
||||
status.textContent = 'Сохраняю…';
|
||||
status.className = 'edit-status';
|
||||
fetch('/funcs/' + ns + '/save-function/' + fnName, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
code: ta.value, filename: filename, kind: fnKind || 'function',
|
||||
deps: depsTA ? depsTA.value : '',
|
||||
deps_filename: depsFilename || ''
|
||||
})
|
||||
}).then(function (r) {
|
||||
saveBtn.disabled = false;
|
||||
if (r.ok) {
|
||||
// Ищем phaseBadge текущей карточки через DOM
|
||||
var cardEl = body.closest ? body.closest('.fn-card') : null;
|
||||
var pb = cardEl ? cardEl.querySelector('.badge-Ready, .badge-Building, .badge-Pending, .badge-Failed, .badge-Error') : null;
|
||||
if (pb) { pb.className = 'badge badge-Building'; pb.textContent = 'Building'; }
|
||||
status.textContent = '⏳ Сборка… 0с';
|
||||
status.className = 'edit-status';
|
||||
// Запускаем поллинг: обновляет статус в строке редактора и badge карточки
|
||||
startBuildPoll(pb, null, { name: fnName, kind: fnKind || 'function' }, ns, status, null);
|
||||
} else {
|
||||
return r.text().then(function (t) {
|
||||
status.textContent = '✗ ' + t;
|
||||
status.className = 'edit-status err';
|
||||
});
|
||||
}
|
||||
}).catch(function (err) {
|
||||
saveBtn.disabled = false;
|
||||
status.textContent = '✗ ' + err;
|
||||
status.className = 'edit-status err';
|
||||
});
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener('click', function () { body.style.display = 'none'; });
|
||||
|
||||
renameBtn.addEventListener('click', function () {
|
||||
var newName = nameInput.value.trim();
|
||||
if (newName === fnName) { renameStatus.textContent = 'Имя не изменилось'; return; }
|
||||
if (!/^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$/.test(newName)) {
|
||||
renameStatus.textContent = '✗ Только строчные буквы, цифры, дефисы';
|
||||
renameStatus.className = 'edit-status err';
|
||||
return;
|
||||
}
|
||||
renameBtn.disabled = true;
|
||||
renameStatus.textContent = 'Переименовываю…';
|
||||
renameStatus.className = 'edit-status';
|
||||
fetch('/funcs/' + ns + '/rename-function/' + fnName, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ new_name: newName, kind: fnKind || 'function' })
|
||||
}).then(function (r) {
|
||||
if (r.ok) {
|
||||
renameStatus.textContent = '✓ Переименовано → ' + newName;
|
||||
renameStatus.className = 'edit-status ok';
|
||||
setTimeout(function () { location.reload(); }, 800);
|
||||
} else {
|
||||
return r.text().then(function (t) {
|
||||
renameStatus.textContent = '✗ ' + t;
|
||||
renameStatus.className = 'edit-status err';
|
||||
});
|
||||
}
|
||||
}).catch(function (err) {
|
||||
renameStatus.textContent = '✗ ' + err;
|
||||
renameStatus.className = 'edit-status err';
|
||||
}).finally(function () { renameBtn.disabled = false; });
|
||||
});
|
||||
}
|
||||
|
||||
// startBuildPoll — опрашивает /fn-status каждые 5с пока phase=Building/Pending.
|
||||
// Показывает таймер-строку в statusEl (если передан), обновляет phaseBadge,
|
||||
// добавляет URL через onUrl-коллбэк когда сервис появляется.
|
||||
function startBuildPoll(phaseBadge, timerEl, fn, ns, statusEl, onUrl) {
|
||||
var buildStart = Date.now();
|
||||
var timerItv = setInterval(function () {
|
||||
var sec = Math.floor((Date.now() - buildStart) / 1000);
|
||||
if (timerEl && timerEl.parentNode) timerEl.textContent = sec + 'с';
|
||||
if (statusEl) statusEl.textContent = '⏳ Сборка… ' + sec + 'с';
|
||||
}, 1000);
|
||||
var pollItv = setInterval(function () {
|
||||
fetch('/funcs/' + ns + '/fn-status/' + fn.name + '?kind=' + (fn.kind || 'function'))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (!d.phase || d.phase === 'Building' || d.phase === 'Pending') return;
|
||||
clearInterval(timerItv);
|
||||
clearInterval(pollItv);
|
||||
if (phaseBadge) {
|
||||
phaseBadge.className = 'badge badge-' + d.phase;
|
||||
phaseBadge.textContent = d.phase;
|
||||
}
|
||||
if (timerEl && timerEl.parentNode) timerEl.parentNode.removeChild(timerEl);
|
||||
if (statusEl) {
|
||||
statusEl.textContent = d.phase === 'Ready'
|
||||
? '✓ Сборка завершена — функция готова'
|
||||
: '✗ Сборка завершилась со статусом: ' + d.phase;
|
||||
statusEl.className = 'edit-status ' + (d.phase === 'Ready' ? 'ok' : 'err');
|
||||
}
|
||||
if (d.url && onUrl) onUrl(d.url);
|
||||
})
|
||||
.catch(function () {});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function makeTriggerBtn(tr, ns) {
|
||||
var btn = $el('button', 'trigger-btn ' + (tr.enabled ? 'state-on' : 'state-off'));
|
||||
btn.textContent = tr.enabled ? '■ стоп' : '▶ запуск';
|
||||
@@ -459,6 +964,7 @@
|
||||
}
|
||||
|
||||
function loadSource(body, ns, fnName, fnKind) {
|
||||
body.innerHTML = ''; // всегда очищаем перед загрузкой
|
||||
var msg = $el('div', 'src-msg');
|
||||
msg.textContent = 'Загрузка кода…';
|
||||
body.appendChild(msg);
|
||||
@@ -506,6 +1012,120 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Модалка создания функции ----------
|
||||
|
||||
var TEMPLATES = {
|
||||
'python3.11': {
|
||||
filename: 'handler.py',
|
||||
code: '# Hello World — Python 3.11\n# Entrypoint: handler.handler\n\ndef handler(event):\n name = event.get("name", "World")\n return {"result": f"Hello, {name}!"}\n',
|
||||
depsFilename: 'requirements.txt',
|
||||
deps: '# Python packages\n# e.g.: requests==2.31.0\n'
|
||||
},
|
||||
'nodejs20': {
|
||||
filename: 'handler.js',
|
||||
code: '// Hello World — Node.js 20\n// Entrypoint: handler.handler\n\nexports.handler = async (event) => {\n const name = event.name || \'World\';\n return { result: `Hello, ${name}!` };\n};\n',
|
||||
depsFilename: 'package.json',
|
||||
deps: '{\n "dependencies": {}\n}\n'
|
||||
}
|
||||
};
|
||||
|
||||
function defaultFilename(runtime) {
|
||||
return TEMPLATES[runtime] ? TEMPLATES[runtime].filename : 'handler.py';
|
||||
}
|
||||
function helloWorldTemplate(runtime) {
|
||||
return TEMPLATES[runtime] ? TEMPLATES[runtime].code : '# код функции\n';
|
||||
}
|
||||
|
||||
var modal = document.getElementById('create-modal');
|
||||
var mRuntime = document.getElementById('m-runtime');
|
||||
var mCode = document.getElementById('m-code');
|
||||
var mFilenameDisplay = document.getElementById('m-filename-display');
|
||||
var mErr = document.getElementById('m-err');
|
||||
|
||||
// Заполняем шаблон при открытии / смене runtime
|
||||
function applyTemplate() {
|
||||
var tpl = TEMPLATES[mRuntime.value];
|
||||
if (tpl) {
|
||||
mCode.value = tpl.code;
|
||||
mFilenameDisplay.textContent = tpl.filename;
|
||||
document.getElementById('m-deps-name').textContent = tpl.depsFilename;
|
||||
document.getElementById('m-deps').value = tpl.deps || '';
|
||||
}
|
||||
}
|
||||
applyTemplate();
|
||||
mRuntime.addEventListener('change', applyTemplate);
|
||||
|
||||
document.getElementById('open-create-modal').addEventListener('click', function () {
|
||||
applyTemplate();
|
||||
mErr.textContent = '';
|
||||
document.getElementById('m-name').value = '';
|
||||
document.getElementById('m-entrypoint').value = 'handler.handler';
|
||||
modal.classList.add('open');
|
||||
document.getElementById('m-name').focus();
|
||||
});
|
||||
|
||||
document.getElementById('modal-cancel').addEventListener('click', function () {
|
||||
modal.classList.remove('open');
|
||||
});
|
||||
modal.addEventListener('click', function (e) {
|
||||
if (e.target === modal) modal.classList.remove('open');
|
||||
});
|
||||
|
||||
document.getElementById('modal-submit').addEventListener('click', function () {
|
||||
var name = document.getElementById('m-name').value.trim();
|
||||
var runtime = mRuntime.value;
|
||||
var entrypoint = document.getElementById('m-entrypoint').value.trim();
|
||||
var code = mCode.value;
|
||||
var filename = TEMPLATES[runtime] ? TEMPLATES[runtime].filename : 'handler.py';
|
||||
|
||||
mErr.textContent = '';
|
||||
if (!name) { mErr.textContent = 'Укажите имя функции'; return; }
|
||||
if (!/^[a-z0-9][a-z0-9\-]{0,61}[a-z0-9]$/.test(name) && !/^[a-z0-9]$/.test(name)) {
|
||||
mErr.textContent = 'Имя должно быть в формате k8s: строчные буквы, цифры, дефисы';
|
||||
return;
|
||||
}
|
||||
if (!entrypoint) { mErr.textContent = 'Укажите entrypoint (например handler.handler)'; return; }
|
||||
if (!code.trim()) { mErr.textContent = 'Напишите код функции'; return; }
|
||||
|
||||
var btn = document.getElementById('modal-submit');
|
||||
btn.disabled = true;
|
||||
mErr.textContent = 'Создаю…';
|
||||
|
||||
fetch('/funcs/' + ns + '/create-function', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
runtime: runtime,
|
||||
kind: 'service',
|
||||
code: code,
|
||||
filename: filename,
|
||||
entrypoint: entrypoint,
|
||||
memory_mb: 128,
|
||||
timeout_sec: 30,
|
||||
deps: document.getElementById('m-deps').value,
|
||||
deps_filename: TEMPLATES[runtime] ? TEMPLATES[runtime].depsFilename : ''
|
||||
})
|
||||
}).then(function (r) {
|
||||
if (r.ok) {
|
||||
modal.classList.remove('open');
|
||||
mErr.textContent = '';
|
||||
// Перезагружаем страницу чтобы увидеть новую функцию
|
||||
setTimeout(function () { location.reload(); }, 400);
|
||||
} else {
|
||||
return r.text().then(function (t) {
|
||||
mErr.textContent = '✗ ' + t;
|
||||
});
|
||||
}
|
||||
}).catch(function (err) {
|
||||
mErr.textContent = '✗ Сетевая ошибка: ' + err;
|
||||
}).finally(function () {
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Утилиты ----------
|
||||
|
||||
function detectLang(name) {
|
||||
var ext = name.split('.').pop().toLowerCase();
|
||||
var m = {
|
||||
|
||||
+398
-7
@@ -1,15 +1,20 @@
|
||||
// Изменено: 2026-03-21 (добавлен вывод sless_service вместе с функциями — пользователь видит оба типа как «функции»)
|
||||
// Изменено: 2026-03-23 (deps поддержка, Building poll, rename, kind=service по умолч.)
|
||||
// main.go — глобальный HTTP сервис листинга функций пользователя.
|
||||
// Развёрнут ОДИН РАЗ в namespace sless; работает для ВСЕХ пользователей.
|
||||
// Не связан с terraform — деплоится манифестом deployments/k8s/funcs-service.yaml.
|
||||
//
|
||||
// Маршруты:
|
||||
// GET /funcs — usage hint (без токена)
|
||||
// GET /funcs?token=<jwt> — листинг через JWT токен
|
||||
// GET /funcs/<namespace> — листинг (Accept: text/html → HTML, иначе plain text)
|
||||
// GET /funcs/<namespace>/source/<fn> — прокси к оператору: файлы исходного кода (JSON)
|
||||
// PATCH /funcs/<namespace>/triggers/<n> — прокси к оператору: enable/disable триггера
|
||||
// GET /health — liveness/readiness probe
|
||||
// GET /funcs — usage hint (без токена)
|
||||
// GET /funcs?token=<jwt> — листинг через JWT токен
|
||||
// GET /funcs/<namespace> — листинг (Accept: text/html → HTML, иначе plain text)
|
||||
// GET /funcs/<namespace>/source/<fn> — прокси: исходный код (JSON)
|
||||
// PATCH /funcs/<namespace>/triggers/<n> — прокси: enable/disable триггера
|
||||
// GET /funcs/<namespace>/fn-status/<fn> — прокси: текущий phase+url функции (для UI polling)
|
||||
// POST /funcs/<namespace>/create-function — создать функцию через UI (kind=service по умолч.)
|
||||
// POST /funcs/<namespace>/save-function/<fn> — обновить код через UI (zip с code+deps)
|
||||
// POST /funcs/<namespace>/rename-function/<fn> — переименовать: delete old + create new с тем же кодом
|
||||
// DELETE /funcs/<namespace>/delete-function/<fn> — удалить функцию через UI
|
||||
// GET /health — liveness/readiness probe
|
||||
//
|
||||
// Env vars:
|
||||
// SLESS_OPERATOR_URL — URL оператора внутри кластера (default: http://sless-operator.sless.svc.cluster.local:9090)
|
||||
@@ -21,7 +26,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
@@ -29,6 +36,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
@@ -180,9 +188,51 @@ func handleFuncsNS(operatorURL, externalURL, serviceToken string, exclude map[st
|
||||
}
|
||||
proxyTriggerPatch(w, r, operatorURL, serviceToken, ns, parts[2])
|
||||
return
|
||||
case "fn-status":
|
||||
// GET /funcs/{ns}/fn-status/{fn} — текущий phase+url для UI polling
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
proxyFnStatus(w, r, operatorURL, serviceToken, ns, parts[2])
|
||||
return
|
||||
case "save-function":
|
||||
// POST /funcs/{ns}/save-function/{fnName} — обновить код функции через UI
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
proxySaveFunction(w, r, operatorURL, serviceToken, ns, parts[2])
|
||||
return
|
||||
case "rename-function":
|
||||
// POST /funcs/{ns}/rename-function/{fn} — переименовать: delete+create с тем же кодом
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
proxyRenameFunction(w, r, operatorURL, serviceToken, ns, parts[2])
|
||||
return
|
||||
case "delete-function":
|
||||
// DELETE /funcs/{ns}/delete-function/{fnName} — удалить функцию через UI
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
proxyDeleteFunction(w, r, operatorURL, serviceToken, ns, parts[2])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 2 && parts[1] == "create-function" {
|
||||
// POST /funcs/{ns}/create-function — создать новую функцию через UI
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
proxyCreateFunction(w, r, operatorURL, serviceToken, ns)
|
||||
return
|
||||
}
|
||||
|
||||
if len(parts) > 1 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -549,3 +599,344 @@ func env(key, fallback string) string {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// proxyCreateFunction обрабатывает POST /funcs/{ns}/create-function.
|
||||
// Создаёт функцию как sless_service (always-on, с URL) если kind не указан.
|
||||
// Принимает code (основной файл) + deps (requirements.txt / package.json).
|
||||
func proxyCreateFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns string) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Runtime string `json:"runtime"`
|
||||
Kind string `json:"kind"` // "service" | "function"; default "service"
|
||||
Code string `json:"code"`
|
||||
Filename string `json:"filename"`
|
||||
Deps string `json:"deps"` // содержимое requirements.txt / package.json
|
||||
DepsFile string `json:"deps_filename"` // "requirements.txt" или "package.json"
|
||||
Entrypoint string `json:"entrypoint"`
|
||||
MemoryMB int `json:"memory_mb"`
|
||||
TimeoutSec int `json:"timeout_sec"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Name == "" || req.Runtime == "" || req.Code == "" || req.Filename == "" || req.Entrypoint == "" {
|
||||
http.Error(w, `{"error":"name, runtime, code, filename, entrypoint are required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !isValidK8sName(req.Name) {
|
||||
http.Error(w, `{"error":"invalid function name"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Kind == "" {
|
||||
req.Kind = "service" // always-on по умолчанию — сразу получает URL
|
||||
}
|
||||
if req.MemoryMB <= 0 {
|
||||
req.MemoryMB = 128
|
||||
}
|
||||
if req.TimeoutSec <= 0 {
|
||||
req.TimeoutSec = 30
|
||||
}
|
||||
|
||||
resourceType := "functions"
|
||||
if req.Kind == "service" {
|
||||
resourceType = "services"
|
||||
}
|
||||
|
||||
// Шаг 1: создаём CRD через оператор (/functions или /services)
|
||||
createBody, _ := json.Marshal(map[string]any{
|
||||
"name": req.Name,
|
||||
"runtime": req.Runtime,
|
||||
"entrypoint": req.Entrypoint,
|
||||
"memory_mb": req.MemoryMB,
|
||||
"timeout_sec": req.TimeoutSec,
|
||||
})
|
||||
createURL := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType
|
||||
createResp, err := operatorRequest(r.Context(), http.MethodPost, createURL, serviceToken, "application/json", bytes.NewReader(createBody))
|
||||
if err != nil || (createResp.StatusCode != http.StatusCreated && createResp.StatusCode != http.StatusOK) {
|
||||
code := http.StatusBadGateway
|
||||
msg := "create CRD failed"
|
||||
if createResp != nil {
|
||||
b, _ := io.ReadAll(createResp.Body)
|
||||
createResp.Body.Close()
|
||||
msg = string(b)
|
||||
code = createResp.StatusCode
|
||||
}
|
||||
http.Error(w, msg, code)
|
||||
return
|
||||
}
|
||||
createResp.Body.Close()
|
||||
|
||||
// Шаг 2: упаковываем code + deps в zip и загружаем
|
||||
zipFiles := map[string]string{req.Filename: req.Code}
|
||||
if req.Deps != "" && req.DepsFile != "" {
|
||||
zipFiles[req.DepsFile] = req.Deps
|
||||
}
|
||||
zipData, err := buildZipFiles(zipFiles)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"zip build failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
uploadURL := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType + "/" + req.Name + "/upload"
|
||||
if err := uploadZipToOperator(r.Context(), uploadURL, serviceToken, req.Filename, zipData); err != nil {
|
||||
http.Error(w, "upload code: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
fmt.Fprintf(w, `{"status":"ok","name":%q,"kind":%q}`, req.Name, req.Kind)
|
||||
}
|
||||
|
||||
// proxySaveFunction обрабатывает POST /funcs/{ns}/save-function/{fnName}.
|
||||
// Принимает code + deps (опционально), упаковывает в zip и загружает через оператор.
|
||||
// kind=service → загружает в /services/{name}/upload, иначе → /functions/{name}/upload.
|
||||
func proxySaveFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, fnName string) {
|
||||
if !isValidK8sName(fnName) {
|
||||
http.Error(w, `{"error":"invalid function name"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
Filename string `json:"filename"`
|
||||
Kind string `json:"kind"` // "function" | "service"
|
||||
Deps string `json:"deps"` // содержимое requirements.txt / package.json
|
||||
DepsFile string `json:"deps_filename"` // имя файла зависимостей
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Code == "" || req.Filename == "" {
|
||||
http.Error(w, `{"error":"code and filename are required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
zipFiles := map[string]string{req.Filename: req.Code}
|
||||
if req.Deps != "" && req.DepsFile != "" {
|
||||
zipFiles[req.DepsFile] = req.Deps
|
||||
}
|
||||
zipData, err := buildZipFiles(zipFiles)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"zip build failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
resourceType := "functions"
|
||||
if req.Kind == "service" {
|
||||
resourceType = "services"
|
||||
}
|
||||
uploadURL := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType + "/" + fnName + "/upload"
|
||||
if err := uploadZipToOperator(r.Context(), uploadURL, serviceToken, req.Filename, zipData); err != nil {
|
||||
http.Error(w, "upload code: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"status":"ok","name":%q}`, fnName)
|
||||
}
|
||||
|
||||
// proxyDeleteFunction обрабатывает DELETE /funcs/{ns}/delete-function/{fnName}.
|
||||
// Проксирует DELETE к оператору. kind=service → /services/{name}, иначе → /functions/{name}.
|
||||
func proxyDeleteFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, fnName string) {
|
||||
if !isValidK8sName(fnName) {
|
||||
http.Error(w, `{"error":"invalid function name"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resourceType := "functions"
|
||||
if r.URL.Query().Get("kind") == "service" {
|
||||
resourceType = "services"
|
||||
}
|
||||
target := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType + "/" + fnName
|
||||
resp, err := operatorRequest(r.Context(), http.MethodDelete, target, serviceToken, "", nil)
|
||||
if err != nil {
|
||||
http.Error(w, "operator error: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
http.Error(w, string(b), resp.StatusCode)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"status":"ok","name":%q}`, fnName)
|
||||
}
|
||||
|
||||
// buildZipFiles упаковывает набор текстовых файлов {filename→content} в zip-архив.
|
||||
// Используется при создании/редактировании функций через UI.
|
||||
// Поддерживает code + deps (requirements.txt / package.json) в одном архиве.
|
||||
func buildZipFiles(files map[string]string) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for name, content := range files {
|
||||
f, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := io.WriteString(f, content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// proxyFnStatus отдаёт текущий phase+url функции/сервиса — используется для UI polling.
|
||||
// Клиент опрашивает каждые 5с пока phase=Building, потом останавливается.
|
||||
func proxyFnStatus(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, fnName string) {
|
||||
if !isValidK8sName(fnName) {
|
||||
http.Error(w, `{"error":"invalid name"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resourceType := "functions"
|
||||
if r.URL.Query().Get("kind") == "service" {
|
||||
resourceType = "services"
|
||||
}
|
||||
resp, err := operatorRequest(r.Context(), http.MethodGet,
|
||||
operatorURL+"/v1/namespaces/"+ns+"/"+resourceType+"/"+fnName, serviceToken, "", nil)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
w.Write(b) //nolint:errcheck
|
||||
}
|
||||
|
||||
// proxyRenameFunction реализует переименование функции: delete old + create new + upload old code.
|
||||
// Это атомарная операция с точки зрения UI, но не транзакционная на уровне k8s.
|
||||
// При сбое на шагах 3-5 старая функция может остаться (UI покажет ошибку).
|
||||
func proxyRenameFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, oldName string) {
|
||||
if !isValidK8sName(oldName) {
|
||||
http.Error(w, `{"error":"invalid old name"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
NewName string `json:"new_name"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.NewName == "" {
|
||||
http.Error(w, `{"error":"new_name required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !isValidK8sName(req.NewName) {
|
||||
http.Error(w, `{"error":"invalid new_name"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resourceType := "functions"
|
||||
if req.Kind == "service" {
|
||||
resourceType = "services"
|
||||
}
|
||||
ctx := r.Context()
|
||||
baseURL := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType
|
||||
|
||||
// Шаг 1: получаем конфиг старой функции (runtime, entrypoint, memory, timeout)
|
||||
cfgResp, err := operatorRequest(ctx, http.MethodGet, baseURL+"/"+oldName, serviceToken, "", nil)
|
||||
if err != nil || cfgResp.StatusCode != http.StatusOK {
|
||||
http.Error(w, "get old config failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
var cfg struct {
|
||||
Runtime string `json:"runtime"`
|
||||
Entrypoint string `json:"entrypoint"`
|
||||
MemoryMB int `json:"memory_mb"`
|
||||
TimeoutSec int `json:"timeout_sec"`
|
||||
Env map[string]string `json:"env"`
|
||||
}
|
||||
json.NewDecoder(cfgResp.Body).Decode(&cfg) //nolint:errcheck
|
||||
cfgResp.Body.Close()
|
||||
|
||||
// Шаг 2: получаем исходный код старой функции
|
||||
srcResp, _ := operatorRequest(ctx, http.MethodGet, baseURL+"/"+oldName+"/source", serviceToken, "", nil)
|
||||
zipFiles := map[string]string{}
|
||||
if srcResp != nil && srcResp.StatusCode == http.StatusOK {
|
||||
var files []struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
Binary bool `json:"binary"`
|
||||
}
|
||||
if json.NewDecoder(srcResp.Body).Decode(&files) == nil {
|
||||
for _, f := range files {
|
||||
if !f.Binary {
|
||||
zipFiles[f.Name] = f.Content
|
||||
}
|
||||
}
|
||||
}
|
||||
srcResp.Body.Close()
|
||||
}
|
||||
|
||||
// Шаг 3: создаём новую функцию с новым именем
|
||||
createBody, _ := json.Marshal(map[string]any{
|
||||
"name": req.NewName, "runtime": cfg.Runtime, "entrypoint": cfg.Entrypoint,
|
||||
"memory_mb": cfg.MemoryMB, "timeout_sec": cfg.TimeoutSec, "env": cfg.Env,
|
||||
})
|
||||
createResp, err := operatorRequest(ctx, http.MethodPost, baseURL, serviceToken, "application/json", bytes.NewReader(createBody))
|
||||
if err != nil || (createResp.StatusCode != http.StatusCreated && createResp.StatusCode != http.StatusOK) {
|
||||
msg := "create new failed"
|
||||
if createResp != nil {
|
||||
b, _ := io.ReadAll(createResp.Body)
|
||||
createResp.Body.Close()
|
||||
msg = string(b)
|
||||
}
|
||||
http.Error(w, msg, http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
createResp.Body.Close()
|
||||
|
||||
// Шаг 4: загружаем старый код в новую функцию (если есть)
|
||||
if len(zipFiles) > 0 {
|
||||
if zipData, err := buildZipFiles(zipFiles); err == nil {
|
||||
_ = uploadZipToOperator(ctx, baseURL+"/"+req.NewName+"/upload", serviceToken, req.NewName, zipData)
|
||||
}
|
||||
}
|
||||
|
||||
// Шаг 5: удаляем старую функцию
|
||||
delResp, _ := operatorRequest(ctx, http.MethodDelete, baseURL+"/"+oldName, serviceToken, "", nil)
|
||||
if delResp != nil {
|
||||
delResp.Body.Close()
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"status":"ok","old":%q,"new":%q,"kind":%q}`, oldName, req.NewName, req.Kind)
|
||||
}
|
||||
|
||||
// uploadZipToOperator отправляет zip-данные как multipart/form-data field "code" на URL оператора.
|
||||
func uploadZipToOperator(ctx context.Context, url, token, filename string, zipData []byte) error {
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
fw, err := mw.CreateFormFile("code", filename+".zip")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fw.Write(zipData); err != nil {
|
||||
return err
|
||||
}
|
||||
mw.Close()
|
||||
|
||||
resp, err := operatorRequest(ctx, http.MethodPost, url, token, mw.FormDataContentType(), &body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("status %d: %s", resp.StatusCode, b)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// operatorRequest выполняет HTTP-запрос к оператору с авторизацией через serviceToken.
|
||||
func operatorRequest(ctx context.Context, method, url, token, contentType string, body io.Reader) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
return http.DefaultClient.Do(req)
|
||||
}
|
||||
|
||||
@@ -253,15 +253,23 @@ func (r *JobResource) Create(ctx context.Context, req resource.CreateRequest, re
|
||||
}
|
||||
}
|
||||
|
||||
// Загружаем код если задан source_dir — контроллер начнёт kaniko сборку после upload
|
||||
// Загружаем код если задан source_dir — контроллер начнёт kaniko сборку после upload.
|
||||
// deleteOnFail — откат: удаляем CR если upload провалился,
|
||||
// иначе при следующем apply будет 409 (CR есть, state пустой).
|
||||
if !plan.SourceDir.IsNull() && plan.SourceDir.ValueString() != "" {
|
||||
zipData, hash, err := zipDir(plan.SourceDir.ValueString())
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("zip source_dir", err.Error())
|
||||
if delErr := r.client.DeleteJob(ctx, ns, plan.Name.ValueString()); delErr != nil {
|
||||
resp.Diagnostics.AddWarning("rollback delete job", delErr.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := r.client.UploadJobCode(ctx, ns, plan.Name.ValueString(), "function.zip", bytes.NewReader(zipData)); err != nil {
|
||||
resp.Diagnostics.AddError("upload job code", err.Error())
|
||||
if delErr := r.client.DeleteJob(ctx, ns, plan.Name.ValueString()); delErr != nil {
|
||||
resp.Diagnostics.AddWarning("rollback delete job", delErr.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
plan.CodeHash = types.StringValue(hash)
|
||||
|
||||
@@ -192,22 +192,31 @@ func (r *ServiceResource) Create(ctx context.Context, req resource.CreateRequest
|
||||
return
|
||||
}
|
||||
|
||||
// deleteOnFail — откат: удаляем CR если upload провалился,
|
||||
// иначе при следующем apply будет 409 (CR есть, state пустой).
|
||||
deleteOnFail := func(addErr string, err error) {
|
||||
resp.Diagnostics.AddError(addErr, err.Error())
|
||||
if delErr := r.client.DeleteService(ctx, ns, svc.Name); delErr != nil {
|
||||
resp.Diagnostics.AddWarning("rollback delete service", delErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
var codeUploaded bool
|
||||
if !plan.SourceDir.IsNull() && plan.SourceDir.ValueString() != "" {
|
||||
zipData, hash, err := zipDir(plan.SourceDir.ValueString())
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("zip source_dir", err.Error())
|
||||
deleteOnFail("zip source_dir", err)
|
||||
return
|
||||
}
|
||||
if err := r.client.UploadServiceCodeReader(ctx, ns, svc.Name, "code.zip", bytes.NewReader(zipData)); err != nil {
|
||||
resp.Diagnostics.AddError("upload service code", err.Error())
|
||||
deleteOnFail("upload service code", err)
|
||||
return
|
||||
}
|
||||
plan.CodeHash = types.StringValue(hash)
|
||||
codeUploaded = true
|
||||
} else if !plan.CodePath.IsNull() && plan.CodePath.ValueString() != "" {
|
||||
if err := r.client.UploadServiceCode(ctx, ns, svc.Name, plan.CodePath.ValueString()); err != nil {
|
||||
resp.Diagnostics.AddError("upload service code", err.Error())
|
||||
deleteOnFail("upload service code", err)
|
||||
return
|
||||
}
|
||||
codeUploaded = true
|
||||
|
||||
Reference in New Issue
Block a user