diff --git a/console/main.go b/console/main.go index 823a832..28a0a76 100644 --- a/console/main.go +++ b/console/main.go @@ -15,6 +15,7 @@ import ( "net" "net/http" "os" + "regexp" "sort" "strconv" "strings" @@ -268,6 +269,12 @@ func (s *server) handleFunctionsAction(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) } +// validFuncName — RFC 1123 subdomain label: lowercase alphanumeric + hyphens, no leading/trailing hyphen, max 63 chars. +var validFuncName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) + +// maxCodeSize — максимальный размер кода функции (1 MB). +const maxCodeSize = 1 << 20 + func (s *server) handleCreateFunction(w http.ResponseWriter, r *http.Request) { var req createFunctionRequest ns := s.userNS(r) @@ -293,6 +300,19 @@ func (s *server) handleCreateFunction(w http.ResponseWriter, r *http.Request) { req.Entrypoint = strings.TrimSpace(req.Entrypoint) req.Route = strings.TrimSpace(req.Route) + // Валидация имени: RFC 1123 label, максимум 57 символов. + // Ограничение 57 (не 63): самый длинный суффикс — "-route" (HTTPTrigger) = 6 символов. + // 63 - 6 = 57. Fission webhook требует все объекты <= 63 символов. + if req.Name != "" && (!validFuncName.MatchString(req.Name) || len(req.Name) > 57) { + writeJSONError(w, http.StatusBadRequest, "invalid function name: must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ and be <= 57 chars") + return + } + // Лимит размера кода + if len(req.Code) > maxCodeSize { + writeJSONError(w, http.StatusBadRequest, "code exceeds 1MB limit") + return + } + // Resolve language → environment (lazy creation). // ensureEnvironment создаёт Environment CRD если не существует — Fission увидит и поднимет pool pod. if req.Language != "" { @@ -398,13 +418,7 @@ func (s *server) handleCreateFunction(w http.ResponseWriter, r *http.Request) { "spec": pkgSpec, }} - if _, err := s.dyn.Resource(packageGVR).Namespace(s.userNS(r)).Create(ctx, pkg, metav1.CreateOptions{}); err != nil { - writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create package: %v", err)) - return - } - - // Парсим TTL — если указан, записываем аннотацию на Function CRD. - // reaper периодически читает эту аннотацию и удаляет протухшие функции + чистит environment если он больше не используется. + // Парсим TTL ДО создания k8s ресурсов — чтобы невалидный TTL не оставлял мусор fnAnnotations := map[string]any{} fnAnnotations["fission-console/language"] = req.Language if req.TTL != "" { @@ -416,6 +430,19 @@ func (s *server) handleCreateFunction(w http.ResponseWriter, r *http.Request) { fnAnnotations["fission-console/expires-at"] = expiresAt.UTC().Format(time.RFC3339) } + if _, err := s.dyn.Resource(packageGVR).Namespace(s.userNS(r)).Create(ctx, pkg, metav1.CreateOptions{}); err != nil { + if apierrors.IsAlreadyExists(err) { + writeJSONError(w, http.StatusConflict, fmt.Sprintf("function %q already exists", req.Name)) + return + } + if apierrors.IsInvalid(err) { + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("invalid function spec: %v", err)) + return + } + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create package: %v", err)) + return + } + fn := &unstructured.Unstructured{Object: map[string]any{ "apiVersion": "fission.io/v1", "kind": "Function", @@ -442,6 +469,10 @@ func (s *server) handleCreateFunction(w http.ResponseWriter, r *http.Request) { if _, err := s.dyn.Resource(functionGVR).Namespace(s.userNS(r)).Create(ctx, fn, metav1.CreateOptions{}); err != nil { _ = s.dyn.Resource(packageGVR).Namespace(s.userNS(r)).Delete(ctx, pkgName, metav1.DeleteOptions{}) + if apierrors.IsAlreadyExists(err) { + writeJSONError(w, http.StatusConflict, fmt.Sprintf("function %q already exists", req.Name)) + return + } writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create function: %v", err)) return } @@ -609,6 +640,7 @@ func (s *server) runExpiryReap() { // reapExpiredFunctionsInNS удаляет протухшие функции в конкретном namespace. // Для каждой удалённой функции вызывает cleanupEnvironmentIfUnused. +// Также удаляет orphan packages — пакеты у которых нет соответствующей функции. func (s *server) reapExpiredFunctionsInNS(ctx context.Context, ns string, now time.Time) { functions, err := s.dyn.Resource(functionGVR).Namespace(ns).List(ctx, metav1.ListOptions{}) if err != nil { @@ -616,6 +648,12 @@ func (s *server) reapExpiredFunctionsInNS(ctx context.Context, ns string, now ti return } + // Строим множество имён существующих функций для поиска orphan packages + activeFunctions := make(map[string]struct{}, len(functions.Items)) + for _, fn := range functions.Items { + activeFunctions[fn.GetName()] = struct{}{} + } + for _, fn := range functions.Items { expiresAtStr, _, _ := unstructured.NestedString(fn.Object, "metadata", "annotations", "fission-console/expires-at") if expiresAtStr == "" { @@ -657,6 +695,24 @@ func (s *server) reapExpiredFunctionsInNS(ctx context.Context, ns string, now ti s.cleanupEnvironmentIfUnused(ctx, ns, envName) } } + + // Сканируем orphan packages — пакеты без соответствующей функции + // (могут остаться если под упал в середине удаления) + packages, pkgListErr := s.dyn.Resource(packageGVR).Namespace(ns).List(ctx, metav1.ListOptions{}) + if pkgListErr == nil { + for _, pkg := range packages.Items { + pkgName := pkg.GetName() + // Конвенция именования: {fn-name}-pkg + if !strings.HasSuffix(pkgName, "-pkg") { + continue + } + fnName := strings.TrimSuffix(pkgName, "-pkg") + if _, exists := activeFunctions[fnName]; !exists { + log.Printf("expiryReaper: deleting orphan package %s/%s (no matching function)", ns, pkgName) + _ = s.dyn.Resource(packageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{}) + } + } + } } func (s *server) buildGoSourceZip(code string) ([]byte, error) { diff --git a/tests_v2.sh b/tests_v2.sh index 25302bf..944dc82 100644 --- a/tests_v2.sh +++ b/tests_v2.sh @@ -3,7 +3,8 @@ set -uo pipefail BASE="https://fission.kube5s.ru/console/api" PASS=0; FAIL=0 -RUN_ID=$(date +%s | tail -c 6) +RUN_ID=$(date +%s%N 2>/dev/null || date +%s)$(od -An -N2 -tu2 /dev/urandom 2>/dev/null | tr -d ' ' || echo "$$") +RUN_ID=$(echo "$RUN_ID" | sha256sum 2>/dev/null | head -c 8 || echo "${RUN_ID}" | cut -c1-8) pass() { PASS=$((PASS+1)); echo " PASS: $1"; } fail() { FAIL=$((FAIL+1)); echo " FAIL: $1"; } @@ -363,6 +364,144 @@ R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/auth \ CODE=$(echo "$R" | tail -1) check_http "$CODE" "401" "POST /auth с невалидным токеном → 401" +# ────────────────────────────────────────────── +echo "" +echo ">>> БЛОК Q: Дубликат → 409, невалидное имя → 400" + +# Создаём функцию, потом снова с тем же именем → 409 +curl -s -X POST ${BASE}/functions \ + -H "X-Test-Sub: dup2-user-${RUN_ID}@test.local" -H "Content-Type: application/json" \ + -d "{\"name\":\"dup-q-${RUN_ID}\",\"language\":\"nodejs\",\"code\":\"module.exports=async()=>({body:'x'})\"}" > /dev/null +sleep 3 +R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ + -H "X-Test-Sub: dup2-user-${RUN_ID}@test.local" -H "Content-Type: application/json" \ + -d "{\"name\":\"dup-q-${RUN_ID}\",\"language\":\"nodejs\",\"code\":\"module.exports=async()=>({body:'x'})\"}") +CODE=$(echo "$R" | tail -1) +check_http "$CODE" "409" "Дубликат → 409 Conflict" + +# Имя с заглавной буквой → 400 +R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ + -H "X-Test-Sub: name-user-${RUN_ID}@test.local" -H "Content-Type: application/json" \ + -d "{\"name\":\"BadName\",\"language\":\"nodejs\",\"code\":\"x\"}") +CODE=$(echo "$R" | tail -1) +check_http "$CODE" "400" "Имя с заглавной буквой → 400" + +# Имя начинается с дефиса → 400 +R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ + -H "X-Test-Sub: name-user-${RUN_ID}@test.local" -H "Content-Type: application/json" \ + -d "{\"name\":\"-badstart\",\"language\":\"nodejs\",\"code\":\"x\"}") +CODE=$(echo "$R" | tail -1) +check_http "$CODE" "400" "Имя с ведущим дефисом → 400" + +# Имя заканчивается дефисом → 400 +R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ + -H "X-Test-Sub: name-user-${RUN_ID}@test.local" -H "Content-Type: application/json" \ + -d "{\"name\":\"badend-\",\"language\":\"nodejs\",\"code\":\"x\"}") +CODE=$(echo "$R" | tail -1) +check_http "$CODE" "400" "Имя с завершающим дефисом → 400" + +# Имя с пробелами → 400 (было 502) +R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ + -H "X-Test-Sub: name-user-${RUN_ID}@test.local" -H "Content-Type: application/json" \ + -d "{\"name\":\"bad name\",\"language\":\"nodejs\",\"code\":\"x\"}") +CODE=$(echo "$R" | tail -1) +check_http "$CODE" "400" "Имя с пробелом → 400 (не 502)" + +# Имя 64 символа → 400 +LONGNAME=$(python3 -c "print('a'*64)") +R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ + -H "X-Test-Sub: name-user-${RUN_ID}@test.local" -H "Content-Type: application/json" \ + -d "{\"name\":\"${LONGNAME}\",\"language\":\"nodejs\",\"code\":\"x\"}") +CODE=$(echo "$R" | tail -1) +check_http "$CODE" "400" "Имя 64 символа → 400" + +# Имя 58 символов → 400 (route = 64 > 63, Fission webhook отклонит) +NAME58=$(python3 -c "print('a'*58)") +R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ + -H "X-Test-Sub: name-user-${RUN_ID}@test.local" -H "Content-Type: application/json" \ + -d "{\"name\":\"${NAME58}\",\"language\":\"nodejs\",\"code\":\"x\"}") +CODE=$(echo "$R" | tail -1) +check_http "$CODE" "400" "Имя 58 символов → 400 (route будет 64 символа)" + +# Имя 57 символов → принято (route = 63 символа, ровно лимит Fission) +NAME57=$(python3 -c "print('a'*57)") +# Первый прогрев namespace +curl -s -X POST ${BASE}/functions \ + -H "X-Test-Sub: len57-user-${RUN_ID}@test.local" -H "Content-Type: application/json" \ + -d '{"name":"warmup","language":"nodejs","code":"module.exports=async()=>({body:\"w\"})"}' > /dev/null +sleep 8 +R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ + -H "X-Test-Sub: len57-user-${RUN_ID}@test.local" -H "Content-Type: application/json" \ + -d "{\"name\":\"${NAME57}\",\"language\":\"nodejs\",\"code\":\"module.exports=async()=>({body:'ok'})\"}") +CODE=$(echo "$R" | tail -1) +check_http "$CODE" "201" "Имя 57 символов → принято (201)" + +# ────────────────────────────────────────────── +echo "" +echo ">>> БЛОК R: Лимит размера кода 1MB" + +# Код > 1MB → 400 +BIG_TMP=$(mktemp) +python3 -c " +import json, sys +code = 'x' * (1024*1024 + 1) +body = json.dumps({'name':'big-fn','language':'nodejs','code':code}) +sys.stdout.write(body) +" > "$BIG_TMP" +R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ + -H "X-Test-Sub: big-user-${RUN_ID}@test.local" -H "Content-Type: application/json" \ + --data @"$BIG_TMP") +rm -f "$BIG_TMP" +CODE=$(echo "$R" | tail -1) +# 400 = наш код, 413 = nginx body limit, 000 = connection reset nginx — всё означает "отклонён" +( [ "$CODE" = "400" ] || [ "$CODE" = "413" ] || [ "$CODE" = "000" ] ) \ + && pass "Код > 1MB → отклонён (HTTP $CODE)" \ + || fail "Код > 1MB → got HTTP $CODE (ожидался 400/413)" + +# Код ровно 1000 символов → не 400 +CODE1K=$(python3 -c "print('module.exports=async()=>({body:' + '\"x\"' + '});' + ' '*(1000-38))") +R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \ + -H "X-Test-Sub: size-user-${RUN_ID}@test.local" -H "Content-Type: application/json" \ + -d "{\"name\":\"size-fn-${RUN_ID}\",\"language\":\"nodejs\",\"code\":\"module.exports=async()=>({body:'ok'})\"}") +CODE=$(echo "$R" | tail -1) +[ "$CODE" = "201" ] && pass "Код 1000 байт → принят (201)" || fail "Код 1000 байт → got $CODE" + +# ────────────────────────────────────────────── +echo "" +echo ">>> БЛОК S: TTL expire + reaper очистка" + +# Создаём функцию с TTL=1m +R=$(curl -s -X POST ${BASE}/functions \ + -H "X-Test-Sub: ttl-exp-${RUN_ID}@test.local" -H "Content-Type: application/json" \ + -d "{\"name\":\"expire-fn-${RUN_ID}\",\"language\":\"nodejs\",\"code\":\"module.exports=async()=>({body:'bye'})\",\"ttl\":\"1m\"}") +CREATED=$(echo "$R" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('name',''))" 2>/dev/null) +[ "$CREATED" = "expire-fn-${RUN_ID}" ] && pass "TTL=1m функция создана" || fail "TTL=1m создание: $R" + +# Сразу должна быть видна в LIST +COUNT=$(curl -s ${BASE}/functions \ + -H "X-Test-Sub: ttl-exp-${RUN_ID}@test.local" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null) +[ "$COUNT" -ge 1 ] 2>/dev/null && pass "TTL=1m функция видна в LIST" || fail "TTL=1m LIST count=$COUNT" + +# ────────────────────────────────────────────── +echo "" +echo ">>> БЛОК T: Параллельное создание одного namespace (race condition)" + +# 5 одновременных запросов от одного пользователя (первый раз) +NS_USER="race-${RUN_ID}@test.local" +RACE_RESULTS="" +for i in $(seq 1 5); do + RACE_CODE="module.exports=async()=>({body:'r${i}'})" + curl -s -X POST ${BASE}/functions \ + -H "X-Test-Sub: ${NS_USER}" -H "Content-Type: application/json" \ + -d "{\"name\":\"race-fn-${i}\",\"language\":\"nodejs\",\"code\":\"${RACE_CODE}\"}" & +done +wait +# Проверяем что namespace создался и функции (хоть часть) прошли +sleep 5 +COUNT=$(curl -s ${BASE}/functions \ + -H "X-Test-Sub: ${NS_USER}" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null) +[ "$COUNT" -ge 1 ] 2>/dev/null && pass "Race condition: namespace создан, функций: $COUNT" || fail "Race condition: functions=$COUNT" + # ────────────────────────────────────────────── echo "" echo "========================================"