diff --git a/doc/progress.md b/doc/progress.md index c217278..b7f8ce0 100644 --- a/doc/progress.md +++ b/doc/progress.md @@ -4,6 +4,39 @@ --- +## 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 ### Что сделано diff --git a/examples/POSTGRES/test_cache_matrix.sh b/examples/POSTGRES/test_cache_matrix.sh index bfd42ef..10d9ddb 100755 --- a/examples/POSTGRES/test_cache_matrix.sh +++ b/examples/POSTGRES/test_cache_matrix.sh @@ -1,12 +1,13 @@ #!/bin/bash -# test_cache_matrix.sh — 2026-03-23 (v3) +# 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 -# ВАЖНО: nubes_postgres.npg и nubes_postgres_database НЕ уничтожаются. -# Причина: пересоздание БД ротирует пароль user0 в k8s secret; -# vault_secrets на это не реагирует сразу → FunctionJob получает старый пароль. +# ВАЖНО: postgres.tf НЕ переименовывается и НЕ трогается никогда. +# Destroy sless-ресурсов делается путём переименования tf-файлов в .tf.bak, +# затем terraform apply (видит что ресурсов нет → удаляет их из state+кластера), +# затем файлы возвращаются обратно. Никаких -target. set -euo pipefail DIR="$(cd "$(dirname "$0")" && pwd)" @@ -38,17 +39,29 @@ timed_op() { } destroy_sless_only() { + # Переименовываем tf-файлы с sless-ресурсами в .tf.bak → terraform apply их удалит. + # Никаких -target — чтобы не затрагивать postgres и не получать state drift. local label="$1" - local targets - targets=$(terraform state list 2>/dev/null | grep -E '^(sless_service|sless_job)' | sed 's/^/-target=/' | tr ' -' ' ' || true) - if [[ -z "$targets" ]]; then + 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 - local n; n=$(terraform state list 2>/dev/null | grep -cE '^(sless_service|sless_job)' || true) - log " Targets: $n resources" - timed_op "$label" terraform destroy $targets -auto-approve + 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" @@ -72,8 +85,52 @@ sep log "PHASE 3: Mixed ops (delete+code+params)" sep -log "--- 3a: destroy targets: stress_divzero, chaos_echo ---" -timed_op "phase3a-destroy-targets" terraform destroy -target=sless_service.stress_divzero -target=sless_service.chaos_echo -auto-approve +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" diff --git a/internal/api/handler/jobs.go b/internal/api/handler/jobs.go index 1883edf..757a1b1 100644 --- a/internal/api/handler/jobs.go +++ b/internal/api/handler/jobs.go @@ -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} @@ -12,6 +13,7 @@ import ( "fmt" "io" "net/http" + "time" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -186,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 } diff --git a/internal/api/handler/services.go b/internal/api/handler/services.go index fd7ee87..f39159d 100644 --- a/internal/api/handler/services.go +++ b/internal/api/handler/services.go @@ -1,6 +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} @@ -352,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 } diff --git a/terraform/provider/internal/resources/job_resource.go b/terraform/provider/internal/resources/job_resource.go index 33b188e..7b7eaf6 100644 --- a/terraform/provider/internal/resources/job_resource.go +++ b/terraform/provider/internal/resources/job_resource.go @@ -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) diff --git a/terraform/provider/internal/resources/service_resource.go b/terraform/provider/internal/resources/service_resource.go index e704c38..0ec6742 100644 --- a/terraform/provider/internal/resources/service_resource.go +++ b/terraform/provider/internal/resources/service_resource.go @@ -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