fix: cache lag retry, go.work, провайдер rollback, test v4 no -target
This commit is contained in:
@@ -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
|
## 2026-03-23 — Сессия 10: ImageExists + деплой v0.1.58
|
||||||
|
|
||||||
### Что сделано
|
### Что сделано
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# test_cache_matrix.sh — 2026-03-23 (v3)
|
# test_cache_matrix.sh — 2026-03-23 (v4)
|
||||||
# Комплексный тест кэша registry:
|
# Комплексный тест кэша registry:
|
||||||
# Phase 1 — полный деплой всех 24 ресурсов (kaniko builds, т.к. нет образов)
|
# Phase 1 — полный деплой всех 24 ресурсов (kaniko builds, т.к. нет образов)
|
||||||
# Phase 2 — destroy sless_* + re-apply (все образы из кэша)
|
# Phase 2 — destroy sless_* + re-apply (все образы из кэша)
|
||||||
# Phase 3 — одновременно: удаление 2, смена кода 2, смена параметров 2
|
# Phase 3 — одновременно: удаление 2, смена кода 2, смена параметров 2
|
||||||
# ВАЖНО: nubes_postgres.npg и nubes_postgres_database НЕ уничтожаются.
|
# ВАЖНО: postgres.tf НЕ переименовывается и НЕ трогается никогда.
|
||||||
# Причина: пересоздание БД ротирует пароль user0 в k8s secret;
|
# Destroy sless-ресурсов делается путём переименования tf-файлов в .tf.bak,
|
||||||
# vault_secrets на это не реагирует сразу → FunctionJob получает старый пароль.
|
# затем terraform apply (видит что ресурсов нет → удаляет их из state+кластера),
|
||||||
|
# затем файлы возвращаются обратно. Никаких -target.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
@@ -38,17 +39,29 @@ timed_op() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
destroy_sless_only() {
|
destroy_sless_only() {
|
||||||
|
# Переименовываем tf-файлы с sless-ресурсами в .tf.bak → terraform apply их удалит.
|
||||||
|
# Никаких -target — чтобы не затрагивать postgres и не получать state drift.
|
||||||
local label="$1"
|
local label="$1"
|
||||||
local targets
|
local SLESS_FILES=("chaos_marathon.tf" "functions.tf" "stress.tf")
|
||||||
targets=$(terraform state list 2>/dev/null | grep -E '^(sless_service|sless_job)' | sed 's/^/-target=/' | tr '
|
|
||||||
' ' ' || true)
|
local has_state
|
||||||
if [[ -z "$targets" ]]; then
|
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)"
|
log " (nothing to destroy for $label — state empty)"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
local n; n=$(terraform state list 2>/dev/null | grep -cE '^(sless_service|sless_job)' || true)
|
log " Hiding sless tf-files → apply will destroy $has_state resources"
|
||||||
log " Targets: $n resources"
|
|
||||||
timed_op "$label" terraform destroy $targets -auto-approve
|
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"
|
cd "$DIR"
|
||||||
@@ -72,8 +85,52 @@ sep
|
|||||||
log "PHASE 3: Mixed ops (delete+code+params)"
|
log "PHASE 3: Mixed ops (delete+code+params)"
|
||||||
sep
|
sep
|
||||||
|
|
||||||
log "--- 3a: destroy targets: stress_divzero, chaos_echo ---"
|
log "--- 3a: destroy stress_divzero, chaos_echo (comment out → apply → restore) ---"
|
||||||
timed_op "phase3a-destroy-targets" terraform destroy -target=sless_service.stress_divzero -target=sless_service.chaos_echo -auto-approve
|
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) ---"
|
log "--- 3b: code changes (new sha256 → kaniko) ---"
|
||||||
echo "" >> "$DIR/code/pg-counter/pg_counter.py"
|
echo "" >> "$DIR/code/pg-counter/pg_counter.py"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Изменено: 2026-03-20 (merge: FunctionJob теперь самодостаточен — убран FunctionRef, добавлены Runtime/Entrypoint/Env)
|
// Изменено: 2026-03-20 (merge: FunctionJob теперь самодостаточен — убран FunctionRef, добавлены Runtime/Entrypoint/Env)
|
||||||
// Изменено: 2026-03-21 (fix: DeleteJob возвращает 404 вместо 204 при отсутствующем объекте)
|
// Изменено: 2026-03-21 (fix: DeleteJob возвращает 404 вместо 204 при отсутствующем объекте)
|
||||||
|
// Изменено: 2026-03-22 (fix: UploadJobCode retry loop против cache lag controller-runtime)
|
||||||
// jobs.go — CRUD handlers для FunctionJob CRD.
|
// jobs.go — CRUD handlers для FunctionJob CRD.
|
||||||
// Создаёт/читает/удаляет k8s FunctionJob ресурсы.
|
// Создаёт/читает/удаляет k8s FunctionJob ресурсы.
|
||||||
// Namespace берётся из URL: /v1/namespaces/{namespace}/jobs/{name}
|
// Namespace берётся из URL: /v1/namespaces/{namespace}/jobs/{name}
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"k8s.io/apimachinery/pkg/api/errors"
|
"k8s.io/apimachinery/pkg/api/errors"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
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)
|
ns := namespace(r)
|
||||||
name := pathVar(r, "name")
|
name := pathVar(r, "name")
|
||||||
|
|
||||||
// Читаем FunctionJob для получения runtime
|
// Читаем FunctionJob для получения runtime.
|
||||||
|
// Retry до 5 раз с задержкой 200мс — защита от cache lag controller-runtime:
|
||||||
|
// сразу после POST /jobs (201) informer cache может ещё не синхронизировать новый CR.
|
||||||
fj := &slessv1alpha1.FunctionJob{}
|
fj := &slessv1alpha1.FunctionJob{}
|
||||||
if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, fj); err != nil {
|
var getJobErr error
|
||||||
if errors.IsNotFound(err) {
|
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"))
|
writeJSON(w, http.StatusNotFound, errResp("job not found"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
|
writeJSON(w, http.StatusInternalServerError, errResp(getJobErr.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Создано: 2026-03-20 (function-service-split)
|
// Создано: 2026-03-20 (function-service-split)
|
||||||
// Изменено: 2026-03-21 (fix: DeleteService возвращает 404 вместо 204 при отсутствующем объекте)
|
// Изменено: 2026-03-21 (fix: DeleteService возвращает 404 вместо 204 при отсутствующем объекте)
|
||||||
// Изменено: 2026-03-22 (fix: CreateService 409 при пересоздании сервиса через terraform -replace)
|
// Изменено: 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).
|
// services.go — CRUD handlers для Service CRD (sless_service).
|
||||||
// sless_service = long-running Deployment + URL. Каждый вызов проксируется к поду.
|
// sless_service = long-running Deployment + URL. Каждый вызов проксируется к поду.
|
||||||
// Namespace берётся из URL: /v1/namespaces/{namespace}/services/{name}
|
// Namespace берётся из URL: /v1/namespaces/{namespace}/services/{name}
|
||||||
@@ -352,13 +353,24 @@ func (h *Handler) UploadServiceCode(w http.ResponseWriter, r *http.Request) {
|
|||||||
ns := namespace(r)
|
ns := namespace(r)
|
||||||
name := pathVar(r, "name")
|
name := pathVar(r, "name")
|
||||||
|
|
||||||
|
// Retry до 5 раз с задержкой 200мс — защита от cache lag controller-runtime.
|
||||||
|
// Проблема: после POST /services (201) кеш informer может не успеть обновиться,
|
||||||
|
// и Get возвращает IsNotFound в течение ~100-400мс после создания CR.
|
||||||
svc := &slessv1alpha1.Service{}
|
svc := &slessv1alpha1.Service{}
|
||||||
if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, svc); err != nil {
|
var getErr error
|
||||||
if errors.IsNotFound(err) {
|
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"))
|
writeJSON(w, http.StatusNotFound, errResp("service not found"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
|
writeJSON(w, http.StatusInternalServerError, errResp(getErr.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() != "" {
|
if !plan.SourceDir.IsNull() && plan.SourceDir.ValueString() != "" {
|
||||||
zipData, hash, err := zipDir(plan.SourceDir.ValueString())
|
zipData, hash, err := zipDir(plan.SourceDir.ValueString())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
resp.Diagnostics.AddError("zip source_dir", err.Error())
|
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
|
return
|
||||||
}
|
}
|
||||||
if err := r.client.UploadJobCode(ctx, ns, plan.Name.ValueString(), "function.zip", bytes.NewReader(zipData)); err != nil {
|
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())
|
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
|
return
|
||||||
}
|
}
|
||||||
plan.CodeHash = types.StringValue(hash)
|
plan.CodeHash = types.StringValue(hash)
|
||||||
|
|||||||
@@ -192,22 +192,31 @@ func (r *ServiceResource) Create(ctx context.Context, req resource.CreateRequest
|
|||||||
return
|
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
|
var codeUploaded bool
|
||||||
if !plan.SourceDir.IsNull() && plan.SourceDir.ValueString() != "" {
|
if !plan.SourceDir.IsNull() && plan.SourceDir.ValueString() != "" {
|
||||||
zipData, hash, err := zipDir(plan.SourceDir.ValueString())
|
zipData, hash, err := zipDir(plan.SourceDir.ValueString())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
resp.Diagnostics.AddError("zip source_dir", err.Error())
|
deleteOnFail("zip source_dir", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := r.client.UploadServiceCodeReader(ctx, ns, svc.Name, "code.zip", bytes.NewReader(zipData)); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
plan.CodeHash = types.StringValue(hash)
|
plan.CodeHash = types.StringValue(hash)
|
||||||
codeUploaded = true
|
codeUploaded = true
|
||||||
} else if !plan.CodePath.IsNull() && plan.CodePath.ValueString() != "" {
|
} else if !plan.CodePath.IsNull() && plan.CodePath.ValueString() != "" {
|
||||||
if err := r.client.UploadServiceCode(ctx, ns, svc.Name, plan.CodePath.ValueString()); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
codeUploaded = true
|
codeUploaded = true
|
||||||
|
|||||||
Reference in New Issue
Block a user