Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a168185ea | ||
|
|
cb77a7f68e | ||
|
|
2e7cd7f4f7 | ||
|
|
c033adec11 |
@@ -115,7 +115,12 @@ func (r *FunctionReconciler) startBuild(ctx context.Context, fn *slessv1alpha1.F
|
|||||||
|
|
||||||
// Проверяем: образ с этим тегом уже существует в registry?
|
// Проверяем: образ с этим тегом уже существует в registry?
|
||||||
// Если да — пропускаем kaniko, сразу переходим в Ready.
|
// Если да — пропускаем kaniko, сразу переходим в Ready.
|
||||||
if r.Builder.ImageExists(ctx, 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 := log.FromContext(ctx)
|
||||||
logger.Info("image already exists in registry, skipping build", "imageRef", imageRef)
|
logger.Info("image already exists in registry, skipping build", "imageRef", imageRef)
|
||||||
|
|
||||||
|
|||||||
@@ -124,7 +124,12 @@ func (r *FunctionJobReconciler) startJobBuild(ctx context.Context, fj *slessv1al
|
|||||||
imageRef := r.Builder.ImageRef(r.OperatorNamespace, fj.Name, fj.Spec.S3Key)
|
imageRef := r.Builder.ImageRef(r.OperatorNamespace, fj.Name, fj.Spec.S3Key)
|
||||||
|
|
||||||
// Проверяем: образ с этим тегом уже существует в registry?
|
// Проверяем: образ с этим тегом уже существует в registry?
|
||||||
if r.Builder.ImageExists(ctx, imageRef) {
|
// Если 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)
|
logger.Info("image already exists in registry, skipping build", "imageRef", imageRef)
|
||||||
|
|
||||||
if fj.Annotations == nil {
|
if fj.Annotations == nil {
|
||||||
|
|||||||
@@ -109,7 +109,12 @@ func (r *ServiceReconciler) startServiceBuild(ctx context.Context, svc *slessv1a
|
|||||||
|
|
||||||
// Проверяем: образ с этим тегом уже существует в registry?
|
// Проверяем: образ с этим тегом уже существует в registry?
|
||||||
// Если да — пропускаем kaniko, сразу переходим в Ready с известным imageRef.
|
// Если да — пропускаем kaniko, сразу переходим в Ready с известным imageRef.
|
||||||
if r.Builder.ImageExists(ctx, 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 := log.FromContext(ctx)
|
||||||
logger.Info("image already exists in registry, skipping build", "imageRef", imageRef)
|
logger.Info("image already exists in registry, skipping build", "imageRef", imageRef)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
### Что сделано
|
### Что сделано
|
||||||
|
|||||||
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-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}
|
||||||
@@ -7,7 +8,9 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
@@ -185,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,8 +234,10 @@ func (h *Handler) UploadJobCode(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Версия на основе timestamp — каждый upload → новый уникальный ключ в S3
|
// Версия = sha256(загруженный zip). Одинаковый код → одинаковый s3Key → cache hit.
|
||||||
version := time.Now().Format("20060102150405")
|
// Изменился код → новый хеш → новый 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()))
|
s3Key, err := h.S3.UploadContext(r.Context(), ns, name, version, buf, int64(buf.Len()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusInternalServerError, errResp("upload to S3: "+err.Error()))
|
writeJSON(w, http.StatusInternalServerError, errResp("upload to S3: "+err.Error()))
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -8,7 +9,9 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
@@ -350,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,7 +397,10 @@ func (h *Handler) UploadServiceCode(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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()))
|
s3Key, err := h.S3.UploadContext(r.Context(), ns, name, version, buf, int64(buf.Len()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusInternalServerError, errResp("upload to S3: "+err.Error()))
|
writeJSON(w, http.StatusInternalServerError, errResp("upload to S3: "+err.Error()))
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
|
|
||||||
"k8s.io/apimachinery/pkg/api/errors"
|
"k8s.io/apimachinery/pkg/api/errors"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
@@ -65,8 +66,10 @@ func (h *Handler) UploadCode(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Версия на основе timestamp — каждый upload → новый уникальный ключ в S3
|
// Версия = sha256(загруженный zip). Одинаковый код → одинаковый s3Key → cache hit.
|
||||||
version := time.Now().Format("20060102150405")
|
// Изменился код → новый хеш → новый 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()))
|
s3Key, err := h.S3.UploadContext(r.Context(), ns, name, version, buf, int64(buf.Len()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusInternalServerError, errResp("upload to S3: "+err.Error()))
|
writeJSON(w, http.StatusInternalServerError, errResp("upload to S3: "+err.Error()))
|
||||||
|
|||||||
+104
-66
@@ -34,47 +34,55 @@ type Projecter interface {
|
|||||||
|
|
||||||
// Builder — управляет сборкой Docker образов через kaniko Jobs в k8s.
|
// Builder — управляет сборкой Docker образов через kaniko Jobs в k8s.
|
||||||
type Builder struct {
|
type Builder struct {
|
||||||
client client.Client
|
client client.Client
|
||||||
builderImage string // образ kaniko
|
builderImage string // образ kaniko
|
||||||
registryHost string // куда пушим образ (DockerHub: "naeel"; Harbor: "host")
|
registryHost string // куда пушим образ (DockerHub: "naeel"; Harbor: "host")
|
||||||
registryProject string // проект/org внутри registry (Harbor: project; DockerHub: пусто)
|
registryProject string // проект/org внутри registry (Harbor: project; DockerHub: пусто)
|
||||||
registrySecret string // имя k8s Secret с docker-кредами для kaniko
|
registrySecret string // имя k8s Secret с docker-кредами для kaniko
|
||||||
s3Endpoint string // откуда kaniko берёт код
|
registryInsecure bool // true = in-cluster HTTP registry, без TLS и авторизации
|
||||||
s3AccessKey string
|
s3Endpoint string // откуда kaniko берёт код
|
||||||
s3SecretKey string
|
s3AccessKey string
|
||||||
s3Bucket string
|
s3SecretKey string
|
||||||
namespace string // namespace где запускаем build Job'ы
|
s3Bucket string
|
||||||
harborClient Projecter // nil если Harbor не используется
|
namespace string // namespace где запускаем build Job'ы
|
||||||
|
harborClient Projecter // nil если Harbor не используется
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config — параметры для создания Builder'а.
|
// Config — параметры для создания Builder'а.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
BuilderImage string
|
BuilderImage string
|
||||||
RegistryHost string
|
RegistryHost string
|
||||||
RegistryProject string // пусто = DockerHub-режим (2 уровня); задан = project-режим (3 уровня)
|
RegistryProject string // пусто = DockerHub-режим (2 уровня); задан = project-режим (3 уровня)
|
||||||
RegistrySecret string // имя k8s Secret с .dockerconfigjson для пуша образов
|
RegistrySecret string // имя k8s Secret с .dockerconfigjson для пуша образов
|
||||||
S3Endpoint string
|
RegistryInsecure bool // true = HTTP registry (in-cluster), kaniko получает --insecure
|
||||||
S3AccessKey string
|
S3Endpoint string
|
||||||
S3SecretKey string
|
S3AccessKey string
|
||||||
S3Bucket string
|
S3SecretKey string
|
||||||
Namespace string
|
S3Bucket string
|
||||||
HarborClient Projecter // nil — Harbor не используется, EnsureProject пропускается
|
Namespace string
|
||||||
|
HarborClient Projecter // nil — Harbor не используется, EnsureProject пропускается
|
||||||
}
|
}
|
||||||
|
|
||||||
// New создаёт новый Builder.
|
// New создаёт новый Builder.
|
||||||
func New(c client.Client, cfg Config) *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{
|
return &Builder{
|
||||||
client: c,
|
client: c,
|
||||||
builderImage: cfg.BuilderImage,
|
builderImage: cfg.BuilderImage,
|
||||||
registryHost: cfg.RegistryHost,
|
registryHost: cfg.RegistryHost,
|
||||||
registryProject: cfg.RegistryProject,
|
registryProject: cfg.RegistryProject,
|
||||||
registrySecret: cfg.RegistrySecret,
|
registrySecret: registrySecret,
|
||||||
s3Endpoint: cfg.S3Endpoint,
|
registryInsecure: cfg.RegistryInsecure,
|
||||||
s3AccessKey: cfg.S3AccessKey,
|
s3Endpoint: cfg.S3Endpoint,
|
||||||
s3SecretKey: cfg.S3SecretKey,
|
s3AccessKey: cfg.S3AccessKey,
|
||||||
s3Bucket: cfg.S3Bucket,
|
s3SecretKey: cfg.S3SecretKey,
|
||||||
namespace: cfg.Namespace,
|
s3Bucket: cfg.S3Bucket,
|
||||||
harborClient: cfg.HarborClient,
|
namespace: cfg.Namespace,
|
||||||
|
harborClient: cfg.HarborClient,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,74 +118,94 @@ func (b *Builder) ImageRef(namespace, funcName, s3Key string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ImageExists проверяет наличие образа в Docker Registry v2 по тегу (без pull).
|
// ImageExists проверяет наличие образа в Docker Registry v2 по тегу (без pull).
|
||||||
// Использует анонимный bearer-token для публичных репо (DockerHub).
|
// Возвращает (true, nil) если образ есть, (false, nil) если нет,
|
||||||
// Возвращает true если образ с таким тегом уже запушен — сборка не нужна.
|
// (false, err) если registry недоступен — контроллер должен requeue, не запускать сборку.
|
||||||
//
|
//
|
||||||
// Почему анонимный токен, а не credentials:
|
// Два режима:
|
||||||
//
|
// - insecure (in-cluster registry:2): HTTP, без TLS, без авторизации
|
||||||
// DockerHub выдаёт pull-token без авторизации для публичных репо через
|
// - secure (DockerHub): HTTPS, анонимный bearer-token для публичных репо
|
||||||
// GET /token?service=registry.docker.io&scope=repository:{repo}:pull
|
func (b *Builder) ImageExists(ctx context.Context, imageRef string) (bool, error) {
|
||||||
// Это стандартный Docker Registry v2 auth flow (RFC 7235).
|
|
||||||
func (b *Builder) ImageExists(ctx context.Context, imageRef string) bool {
|
|
||||||
// imageRef вида: "naeel/slessffd1-pg-search:47cab27ada70"
|
|
||||||
// или "host/project/func:tag" — разбираем по последнему ":"
|
|
||||||
colonIdx := strings.LastIndex(imageRef, ":")
|
colonIdx := strings.LastIndex(imageRef, ":")
|
||||||
if colonIdx < 0 {
|
if colonIdx < 0 {
|
||||||
return false
|
return false, nil
|
||||||
}
|
}
|
||||||
repoFull := imageRef[:colonIdx]
|
repoFull := imageRef[:colonIdx]
|
||||||
tag := imageRef[colonIdx+1:]
|
tag := imageRef[colonIdx+1:]
|
||||||
|
|
||||||
// Определяем registry host и repo path.
|
if b.registryInsecure {
|
||||||
// DockerHub: "naeel/sless-ff-pg" → registry = index.docker.io, repo = "naeel/sless-ff-pg"
|
// In-cluster HTTP registry — без авторизации и TLS.
|
||||||
// Приватный: "harbor.host/proj/func" → registry = "harbor.host", repo = "proj/func"
|
// 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"
|
registryHost := "index.docker.io"
|
||||||
repo := repoFull
|
repo := repoFull
|
||||||
if parts := strings.SplitN(repoFull, "/", 3); len(parts) == 3 {
|
if parts := strings.SplitN(repoFull, "/", 3); len(parts) == 3 {
|
||||||
// host/project/name — кастомный registry
|
|
||||||
registryHost = parts[0]
|
registryHost = parts[0]
|
||||||
repo = parts[1] + "/" + parts[2]
|
repo = parts[1] + "/" + parts[2]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получаем анонимный/публичный bearer-token для pull доступа к репо.
|
|
||||||
// DockerHub: https://auth.docker.io/token?service=registry.docker.io&scope=repository:{repo}:pull
|
|
||||||
// Для приватных registry этот шаг вернёт 401 → ImageExists вернёт false → пойдём строить.
|
|
||||||
tokenURL := fmt.Sprintf("https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull", repo)
|
tokenURL := fmt.Sprintf("https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull", repo)
|
||||||
if registryHost != "index.docker.io" {
|
if registryHost != "index.docker.io" {
|
||||||
// Для не-DockerHub registry: пробуем без токена (Harbor с allow anon push)
|
|
||||||
// Если 401 — false, пусть builder разберётся.
|
|
||||||
tokenURL = ""
|
tokenURL = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
httpClient := &http.Client{Timeout: 5 * time.Second}
|
httpClient := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
|
||||||
var bearerToken string
|
var bearerToken string
|
||||||
if tokenURL != "" {
|
if tokenURL != "" {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false, nil
|
||||||
}
|
}
|
||||||
resp, err := httpClient.Do(req)
|
resp, err := httpClient.Do(req)
|
||||||
if err != nil || resp.StatusCode != http.StatusOK {
|
if err != nil || resp.StatusCode != http.StatusOK {
|
||||||
return false
|
return false, nil
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
var tkResp struct {
|
var tkResp struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&tkResp); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&tkResp); err != nil {
|
||||||
return false
|
return false, nil
|
||||||
}
|
}
|
||||||
bearerToken = tkResp.Token
|
bearerToken = tkResp.Token
|
||||||
}
|
}
|
||||||
|
|
||||||
// HEAD /v2/{repo}/manifests/{tag} — проверяем наличие тега без скачивания слоёв.
|
|
||||||
manifestURL := fmt.Sprintf("https://%s/v2/%s/manifests/%s", registryHost, repo, tag)
|
manifestURL := fmt.Sprintf("https://%s/v2/%s/manifests/%s", registryHost, repo, tag)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, manifestURL, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodHead, manifestURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false, nil
|
||||||
}
|
}
|
||||||
// Docker Registry v2 требует Accept header для манифестов.
|
|
||||||
req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json")
|
req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json")
|
||||||
if bearerToken != "" {
|
if bearerToken != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+bearerToken)
|
req.Header.Set("Authorization", "Bearer "+bearerToken)
|
||||||
@@ -185,10 +213,10 @@ func (b *Builder) ImageExists(ctx context.Context, imageRef string) bool {
|
|||||||
|
|
||||||
resp, err := httpClient.Do(req)
|
resp, err := httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false, nil
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
return resp.StatusCode == http.StatusOK
|
return resp.StatusCode == http.StatusOK, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build запускает kaniko Job для сборки образа функции.
|
// Build запускает kaniko Job для сборки образа функции.
|
||||||
@@ -230,11 +258,7 @@ func (b *Builder) Build(ctx context.Context, namespace, funcName, s3Key string)
|
|||||||
{
|
{
|
||||||
Name: "kaniko",
|
Name: "kaniko",
|
||||||
Image: b.builderImage,
|
Image: b.builderImage,
|
||||||
Args: []string{
|
Args: b.kanikoArgs(s3ContextURL, imageRef),
|
||||||
"--context=" + s3ContextURL,
|
|
||||||
"--destination=" + imageRef,
|
|
||||||
// --no-cache не поддерживается этой версией kaniko; кэш отключён по умолчанию
|
|
||||||
},
|
|
||||||
Env: []corev1.EnvVar{
|
Env: []corev1.EnvVar{
|
||||||
{Name: "AWS_ACCESS_KEY_ID", Value: b.s3AccessKey},
|
{Name: "AWS_ACCESS_KEY_ID", Value: b.s3AccessKey},
|
||||||
{Name: "AWS_SECRET_ACCESS_KEY", Value: b.s3SecretKey},
|
{Name: "AWS_SECRET_ACCESS_KEY", Value: b.s3SecretKey},
|
||||||
@@ -298,6 +322,20 @@ func (b *Builder) Cleanup(ctx context.Context, jobName string) error {
|
|||||||
return b.client.Delete(ctx, job, &client.DeleteOptions{PropagationPolicy: &propagation})
|
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 задан.
|
// dockerConfigVolumes возвращает Volume с docker-кредами если RegistrySecret задан.
|
||||||
// Ключ .dockerconfigjson монтируется как config.json — стандартное имя для kaniko.
|
// Ключ .dockerconfigjson монтируется как config.json — стандартное имя для kaniko.
|
||||||
func (b *Builder) dockerConfigVolumes() []corev1.Volume {
|
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+).
|
// Go: multi-stage build с go.work (v0.1.3+).
|
||||||
// base-образ содержит: /app/server/ (module sless/fn/server, package main).
|
// base-образ содержит: /app/server/ (module sless/fn/server, package main).
|
||||||
// kaniko копирует пользовательский код в /app/handler/.
|
// kaniko копирует пользовательский код в /app/handler/.
|
||||||
// Генерируем go.mod для handler если его нет (module sless/fn/handler).
|
// Модуль handler всегда переименовывается в sless/fn/handler — это позволяет server.go
|
||||||
// Генерируем go.work: use ./server + use ./handler + replace sless/fn/handler => ./handler.
|
// импортировать его по фиксированному пути без replace-директивы.
|
||||||
// replace позволяет server.go импортировать sless/fn/handler независимо от имени модуля пользователя.
|
// Почему нет replace: Go 1.23 запрещает replace для модуля, который одновременно в workspace
|
||||||
// GOFLAGS=-mod=mod: позволяет go build подтягивать зависимости из handler/go.mod через GOPROXY.
|
// (use ./handler). Ошибка: "workspace module sless/fn/handler is replaced at all versions".
|
||||||
|
// Решение: rename module → use workspace → require без replace.
|
||||||
goModStep := ""
|
goModStep := ""
|
||||||
if !hasGoMod {
|
if !hasGoMod {
|
||||||
// Пользователь не предоставил go.mod — генерируем минимальный
|
// Пользователь не предоставил go.mod — генерируем минимальный с правильным именем.
|
||||||
goModStep = "RUN printf 'module sless/fn/handler\\n\\ngo 1.23\\n' > /app/handler/go.mod\n"
|
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(
|
content := fmt.Sprintf(
|
||||||
"FROM %s AS builder\n"+
|
"FROM %s AS builder\n"+
|
||||||
"WORKDIR /app\n"+
|
"WORKDIR /app\n"+
|
||||||
"COPY . /app/handler/\n"+
|
"COPY . /app/handler/\n"+
|
||||||
goModStep+
|
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.
|
// go.work: workspace из двух модулей без replace-директив.
|
||||||
// Патчим server/go.mod в момент kaniko-сборки (не меняет базовый образ).
|
"RUN printf 'go 1.23\\n\\nuse ./server\\nuse ./handler\\n' > /app/go.work\n"+
|
||||||
"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"+
|
// Патчим 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"+
|
"FROM alpine:3.20\n"+
|
||||||
"COPY --from=builder /server /server\n"+
|
"COPY --from=builder /server /server\n"+
|
||||||
"EXPOSE 8080\n"+
|
"EXPOSE 8080\n"+
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ type Config struct {
|
|||||||
// Создаётся через hack/create-registry-secret.sh
|
// Создаётся через hack/create-registry-secret.sh
|
||||||
RegistrySecret string
|
RegistrySecret string
|
||||||
|
|
||||||
|
// RegistryInsecure — использовать HTTP (без TLS) для registry.
|
||||||
|
// Нужно для in-cluster registry:2 где нет сертификата.
|
||||||
|
// Включается через REGISTRY_INSECURE=true.
|
||||||
|
RegistryInsecure bool
|
||||||
|
|
||||||
// BuilderImage — образ для сборки функций (kaniko или buildah)
|
// BuilderImage — образ для сборки функций (kaniko или buildah)
|
||||||
BuilderImage string
|
BuilderImage string
|
||||||
|
|
||||||
@@ -128,6 +133,11 @@ func Load() (*Config, error) {
|
|||||||
cfg.RegistrySecret = "sless-registry-auth"
|
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 != "" {
|
if v := os.Getenv("BUILDER_IMAGE"); v != "" {
|
||||||
cfg.BuilderImage = v
|
cfg.BuilderImage = v
|
||||||
|
|||||||
@@ -131,16 +131,17 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bldr := builder.New(mgr.GetClient(), builder.Config{
|
bldr := builder.New(mgr.GetClient(), builder.Config{
|
||||||
BuilderImage: cfg.BuilderImage,
|
BuilderImage: cfg.BuilderImage,
|
||||||
RegistryHost: cfg.RegistryHost,
|
RegistryHost: cfg.RegistryHost,
|
||||||
RegistryProject: cfg.RegistryProject,
|
RegistryProject: cfg.RegistryProject,
|
||||||
RegistrySecret: cfg.RegistrySecret,
|
RegistrySecret: cfg.RegistrySecret,
|
||||||
S3Endpoint: cfg.S3Endpoint,
|
RegistryInsecure: cfg.RegistryInsecure,
|
||||||
S3AccessKey: cfg.S3AccessKey,
|
S3Endpoint: cfg.S3Endpoint,
|
||||||
S3SecretKey: cfg.S3SecretKey,
|
S3AccessKey: cfg.S3AccessKey,
|
||||||
S3Bucket: cfg.S3Bucket,
|
S3SecretKey: cfg.S3SecretKey,
|
||||||
Namespace: "sless",
|
S3Bucket: cfg.S3Bucket,
|
||||||
HarborClient: harborProjecter,
|
Namespace: "sless",
|
||||||
|
HarborClient: harborProjecter,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err = (&controllers.FunctionReconciler{
|
if err = (&controllers.FunctionReconciler{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Изменено: 2026-03-22 — v0.1.3: server/ субдиректория + go.work для поддержки пользовательских go.mod.
|
# Изменено: 2026-03-23 — v0.1.4: fix go.work (убран replace, добавлен sed rename модуля).
|
||||||
# Base builder image для Go 1.23 serverless функций.
|
# Base builder image для Go 1.23 serverless функций.
|
||||||
# Это BUILDER-образ: golang:1.23-alpine + server/ + pre-cached зависимости.
|
# Это BUILDER-образ: golang:1.23-alpine + server/ + pre-cached зависимости.
|
||||||
# go mod download кеширует pgx/v5 в /root/go/pkg/mod — kaniko не скачивает их при каждой сборке.
|
# go mod download кеширует pgx/v5 в /root/go/pkg/mod — kaniko не скачивает их при каждой сборке.
|
||||||
@@ -7,14 +7,16 @@
|
|||||||
# FROM naeel/sless-runtime-go1.23:v0.1.3 AS builder
|
# FROM naeel/sless-runtime-go1.23:v0.1.3 AS builder
|
||||||
# WORKDIR /app
|
# WORKDIR /app
|
||||||
# COPY . /app/handler/
|
# COPY . /app/handler/
|
||||||
# RUN [ -f /app/handler/go.mod ] || printf 'module sless/fn/handler\n\ngo 1.23\n' > /app/handler/go.mod
|
# 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\nreplace sless/fn/handler => ./handler\n' > /app/go.work
|
# 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
|
# RUN CGO_ENABLED=0 go build -o /server ./server
|
||||||
# FROM alpine:3.20
|
# FROM alpine:3.20
|
||||||
# COPY --from=builder /server /server
|
# COPY --from=builder /server /server
|
||||||
#
|
#
|
||||||
# Почему server/ субдиректория: go.work + replace позволяет пользователю иметь любой go.mod
|
# Почему replace убран: Go 1.23 запрещает replace для модуля, который одновременно в workspace.
|
||||||
# с любыми зависимостями. Вложенные модули (nested) поддерживаются через 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
|
FROM golang:1.23-alpine
|
||||||
|
|
||||||
|
|||||||
@@ -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