fix: semaphore+singleflight for ensureUserNS, fix 504 on 10 parallel new users (v0.8.12)

This commit is contained in:
Naeel
2026-04-25 14:41:35 +03:00
parent ca43d0a7d7
commit 3b9c6e2c5e
15 changed files with 3414 additions and 67 deletions
+91
View File
@@ -0,0 +1,91 @@
# fission-console v0.6.9 — Отчёт о тестировании
**Дата:** 2026-04-19
**Версия:** `naeel/fission-console:v0.6.9`
**Ветка:** `feat/namespace-isolation`
---
## Что реализовано
Managed serverless functions service поверх Fission + Kubernetes. Go-бэкенд (консоль), REST API, k8s dynamic client. Пользователи изолированы по namespace (`fission-{SHA256(sub)[:16]}`).
**Поддерживаемые среды выполнения:**
- Node.js 22 (ESM, `module.exports` / `export default` / анонимная функция)
- Python 3.11 (Flask, `def main():` без аргументов)
**Auth:** JWT или TEST_MODE (`X-Test-Sub: user@domain` → SHA256 → namespace)
---
## Баги исправлены в этой сессии (v0.6.8 → v0.6.9)
| # | Баг | Симптом | Фикс |
|---|-----|---------|------|
| 1 | Route collision | Два разных пользователя создавали функцию с одинаковым маршрутом | Префикс последних 12 символов namespace: `/{ns[-12:]}/{fn-name}` |
| 2 | INVOKE несуществующей → 200 | Вызов несуществующей функции возвращал HTTP 200 без ошибки | GET из k8s перед invoke, `IsNotFound` → 404 |
| 3 | DELETE несуществующей → 200 | Удаление несуществующей функции возвращало `deleted:true` | Инвертирован `if err == nil` → `if err != nil`, 404 |
| 4 | Orphan package при невалидном TTL | `parseTTL` вызывался после создания пакета — при ошибке оставался мусорный package в k8s | `parseTTL` перемещён **до** создания k8s-ресурсов |
---
## Результаты тестирования
```
PASS=41 FAIL=0
```
**Покрытие (16 блоков):**
| Блок | Что проверяется |
|------|----------------|
| A | Регресс v0.6.8: INVOKE/DELETE несуществующей → 404, route isolation |
| B | Валидация входных данных → 400 (missing name/code/language, пробелы) |
| C | Дублирующее создание → конфликт |
| D | UPDATE кода + повторный invoke возвращает новый код |
| E | GET функции (200/404) |
| F | LIST изоляция — пользователь видит только свои функции |
| G | Namespace изоляция DELETE — нельзя удалить чужую функцию |
| H | Python `def main():` (без аргументов) → invoke 200 |
| I | Node.js: `module.exports`, `export.handler`, `export default` |
| J | TTL: expires_at создаётся / не создаётся / невалидный → 400 |
| K | `ctx.request.url` доступен внутри функции |
| L | Stress: 10 параллельных invoke → 10/10 OK |
| M | LIST пустого namespace → `[]` |
| N | DELETE полная цепочка: Function + Package + HTTPTrigger удаляются |
| O | Запрос без auth-заголовка → 401 |
| P | POST /auth: без токена → 400, невалидный токен → 401 |
---
## Архитектурные решения — вопросы для ревью
### 1. Namespace on-demand
Namespace создаётся при первом обращении пользователя. Нет отдельного registration flow.
**Вопрос:** правильно ли это? Нет ли рисков при параллельном первом запросе от одного пользователя (race condition на создание namespace)?
### 2. Сборка функций
Код пользователя пакуется в zip в памяти и передаётся в Fission Package как `literal` (base64). При обновлении создаётся новый Package, старый удаляется.
**Вопрос:** нет версионирования. Стоит ли хранить историю версий?
### 3. Reaper
Горутина раз в 30 секунд проверяет `expires-at` аннотацию и удаляет протухшие функции вместе с Package и HTTPTrigger. Работает без персистентного стейта — при рестарте пода начинает заново со следующего цикла.
**Вопрос:** надёжно ли это? Что если под упал в момент удаления — останется ли мусор?
### 4. Python env и сигнатура main()
Официальный `ghcr.io/fission/python-env` вызывает `main(*args)` через Flask. Реально аргументы не передаются (только через route params). При `def main():` работает. При `def main(ctx):` — `TypeError: main() missing 1 required positional argument`.
**Вопрос:** патчить env или задокументировать ограничение?
### 5. Валидация имени функции
Имя с пробелами возвращает 502 (k8s отклоняет по RFC 1123), а не 400 (явная валидация в API).
**Вопрос:** стоит ли добавить regex-валидацию имени на уровне API до обращения в k8s?
---
## Что ещё не сделано
- [ ] Версионирование функций
- [ ] Явная валидация имени функции (RFC 1123) в API
- [ ] Метрики / биллинг
- [ ] Документация API (OpenAPI spec)
- [ ] Merge в master
+110
View File
@@ -0,0 +1,110 @@
# Задачи после code review (2026-04-19)
Приоритет: **критично** → сделать до merge в master.
---
## КРИТИЧНО
### 1. k8s ошибки протекают как 502 — нужны правильные HTTP коды
**Файл:** `console/main.go`
**Проблема:** `apierrors.IsAlreadyExists` и `apierrors.IsInvalid` не перехватываются → клиент получает 502 вместо 409/400.
**Что сделать:**
- В `handleCreateFunction`: перехватить `apierrors.IsAlreadyExists` → HTTP 409
- В `handleCreateFunction`: перехватить `apierrors.IsInvalid` → HTTP 400
- Аналогично проверить `handleUpdateFunction`
**Пример:**
```go
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
}
```
---
### 2. Валидация имени функции на уровне API
**Файл:** `console/main.go`
**Проблема:** имя с пробелами/спецсимволами уходит в k8s и возвращается 502.
**Что сделать:** добавить regex-валидацию сразу после парсинга запроса в `handleCreateFunction` и `handleUpdateFunction`.
**Пример:**
```go
var validName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
if !validName.MatchString(req.Name) || len(req.Name) > 63 {
writeJSONError(w, http.StatusBadRequest, "invalid function name: must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ and be <= 63 chars")
return
}
```
---
## ВАЖНО (не блокирует merge)
### 3. Reaper: сканирование orphan packages
**Файл:** `console/main.go`
**Проблема:** если под упал в момент удаления функции, Package может остаться без matching Function.
**Что сделать:** в цикле reaper дополнительно итерироваться по packages и удалять те, у которых нет соответствующей function с тем же именем (по конвенции `{fn-name}-pkg`).
---
### 4. Лимит размера кода
**Файл:** `console/main.go`
**Проблема:** нет ограничения на размер `req.Code` — можно залить мегабайты.
**Что сделать:** после парсинга тела запроса добавить:
```go
const maxCodeSize = 1 << 20 // 1 MB
if len(req.Code) > maxCodeSize {
writeJSONError(w, http.StatusBadRequest, "code exceeds 1MB limit")
return
}
```
---
### 5. Namespace race condition
**Файл:** `console/main.go`
**Проблема:** при одновременных первых запросах одного пользователя `Create(namespace)` может вернуть `AlreadyExists`.
**Что сделать:** убедиться что в `ensureNamespace` (или аналогичной функции) ошибка `AlreadyExists` при создании namespace игнорируется:
```go
if err != nil && !apierrors.IsAlreadyExists(err) {
return err
}
```
---
## НЕ СРОЧНО
### 6. Тест-скрипт: RUN_ID уникальность
**Файл:** `tests_v2.sh`
**Проблема:** два параллельных запуска с одинаковым timestamp дают одинаковый RUN_ID → Block D FAIL.
**Что сделать:** добавить случайный суффикс:
```bash
RUN_ID=$(date +%s%N | sha256sum | head -c 8)
```
---
## Документировать (без кода)
- Python env: `def main():` без аргументов — задокументировать в README/examples
- Версионирование функций — не делать сейчас, отложить
+81
View File
@@ -0,0 +1,81 @@
# Мнения AI-моделей об архитектуре Fission Console — 2026-04-25
> ⚠️ Всё ниже — МНЕНИЯ (не факты). Проверять и применять критически.
---
## GEMINI — мнение (пересказ, апрель 2026)
**Контекст вопроса**: "написан сервис на основе Fission, многопользовательский, с Terraform"
**Что сказал:**
1. **Изоляция**: namespace-per-user — самый надёжный путь. Terraform при создании аккаунта создаёт Namespace + ResourceQuota + NetworkPolicy.
2. **Fission в multi-tenancy**: можно один инстанс Fission на весь кластер, но нужно патчить Router чтобы он понимал принадлежность Environment. Либо (сложнее) — отдельный инстанс Fission/пул экзекуторов на каждый namespace.
3. **Terraform как Control Plane**: кастомный провайдер должен управлять полным lifecycle — регистрация пользователя в БД, создание K8s ресурсов, создание Fission ресурсов. State изолирован между пользователями.
4. **poolmgr vs newdeploy**: poolmgr хорош для 100ms cold start, но при 1000 пользователей — RAM кончается (не масштабируется). Рекомендует NewDeploy + HPA для multi-tenant scale.
5. **Безопасность выполнения**: gVisor/Kata Containers (RuntimeClass=runsc). Для публичного облака — обязательно.
6. **Схема**: user → .tf файл → Terraform Worker → K8s Namespace+лимиты → Fission деплоит пакет → Router → внешний трафик в нужный Namespace.
**Оценка контекста**: Gemini отвечал на абстрактный вопрос "как строить managed serverless". Не знал деталей нашей реализации. Часть советов — для масштаба 1000+ пользователей.
---
## GPT-4.5 — мнение (апрель 2026)
**Контекст вопроса**: тот же — многопользовательский managed serverless на Fission
**Что сказал:**
*(Полный текст ответа не сохранён — пользователь описал его как "насочинял", детали были в чате)*
> ⚠️ Полный текст GPT-4.5 не задокументирован. Пользователь охарактеризовал ответ скептически ("насочинял"). Считать неверифицированным.
---
## Claude Sonnet 4.6 (я) — мнение (апрель 2026)
**Контекст**: знаю реальную реализацию (читал main.go, deploy/, repo memory)
**Для масштаба ~20 одновременных пользователей:**
**Что реально надо доделать:**
1. **ResourceQuota + LimitRange** на каждый user namespace — одна функция сейчас может сожрать все ресурсы кластера. Базовая безопасность, не опционально.
2. **NetworkPolicy** — без этого функции разных пользователей видят друг друга внутри кластера.
3. **`fission_simple_function` Terraform ресурс** — сейчас провайдер low-level: нужно вручную создавать Environment + Package + Function + HTTPTrigger. Нужен один высокоуровневый ресурс.
4. **Поверхность ошибок в UI** — при провале specialization пользователь видит "вероятно не удалось". Надо тянуть реальную причину (статус package, логи executor).
**Что НЕ нужно для 20 юзеров:**
- gVisor — лишняя сложность для контролируемой среды
- newdeploy вместо poolmgr — poolmgr нормально работает, достаточно подстроить poolsize
- Отдельный billing service — достаточно логировать invoke в postgres
**Главный тезис**: Gemini описал правильную архитектуру для SaaS на 10k пользователей. Для 20 — текущая архитектура достаточна. Нужна доводка, не redesign.
---
## Текущее состояние реализации (факты, не мнение)
- namespace-per-user ✅
- auth token → user namespace ✅
- lazy environments per language ✅
- RBAC для системных и локальных SA (fission-fetcher, fission-builder) ✅
- TTL cleanup + reaper ✅
- NSReconciler для FISSION_RESOURCE_NAMESPACES ✅
- Route prefix per user ✅
- Terraform provider (low-level CRD) ✅
- ResourceQuota / LimitRange per namespace ❌ нет
- NetworkPolicy per namespace ❌ нет
- fission_simple_function Terraform ресурс ❌ не реализован
- Нормальная поверхность ошибок в UI ❌ частично
- gVisor/Kata ❌ нет (осознанное решение)
- Billing/metering ❌ нет
+271
View File
@@ -0,0 +1,271 @@
# План доработки Fission Console — 2026-04-25
> Для нового чата. Масштаб: ~20 одновременных пользователей.
> Текущая версия: `naeel/fission-console:v0.8.8`
> Ветка: `feat/namespace-isolation`
---
## Контекст (кратко)
- Go-бэкенд `console/main.go` + embedded UI `console/ui/index.html`
- Каждый пользователь → отдельный K8s namespace `fission-<hash(sub)>`
- Fission core один на кластер, namespace регистрируется через NSReconciler
- Terraform provider: низкоуровневый (Environment + Package + Function + HTTPTrigger)
- API: `https://fission.kube5s.ru/console/api`, тест: `X-Test-Sub: livetest@test.local`
- SSH: `ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213`
- sshfs: `/home/naeel/remote_dev/fission/` = `~/terra/fission/` на VM
---
## Задача 1: ResourceQuota + LimitRange на user namespace (ПРИОРИТЕТ 1)
**Зачем**: без квот одна функция может съесть весь CPU/RAM кластера.
**Где делать**: `console/main.go`, функция `ensureUserNamespace`.
**Что добавить**: при создании namespace применять два объекта:
```yaml
# ResourceQuota
apiVersion: v1
kind: ResourceQuota
metadata:
name: user-quota
namespace: fission-<hash>
spec:
hard:
requests.cpu: "2"
requests.memory: "2Gi"
limits.cpu: "4"
limits.memory: "4Gi"
pods: "20"
count/functions.fission.io: "20"
count/packages.fission.io: "40"
```
```yaml
# LimitRange
apiVersion: v1
kind: LimitRange
metadata:
name: user-limits
namespace: fission-<hash>
spec:
limits:
- type: Container
default:
cpu: "500m"
memory: "256Mi"
defaultRequest:
cpu: "100m"
memory: "64Mi"
max:
cpu: "2"
memory: "1Gi"
```
**Детали реализации**:
- Применять через `k8s dynamic client` или `core client` (уже есть в main.go)
- Idempotent: если уже есть — не ошибаться, просто пропустить (patch или get+create)
- Значения вынести в константы (или env vars) для удобной настройки
**Файлы**: `console/main.go`
**Тест**: создать нового пользователя, проверить `kubectl get resourcequota,limitrange -n fission-<hash>`
---
## Задача 2: NetworkPolicy на user namespace (ПРИОРИТЕТ 2)
**Зачем**: без NetworkPolicy функции разных юзеров могут напрямую обращаться друг к другу по cluster IP.
**Где делать**: `console/main.go`, функция `ensureUserNamespace` (вместе с задачей 1).
**Что создать**: два правила:
```yaml
# Deny all ingress from other namespaces (кроме fission core)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-cross-tenant
namespace: fission-<hash>
spec:
podSelector: {}
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: fission # fission core может
- podSelector: {} # внутри namespace — OK
```
**Осторожно**: проверить что fission router/executor/fetcher namespace правильно лейблован. Если нет — добавить лейбл на namespace `fission` через kubectl.
**Тест**: поднять функцию в namespace A, попробовать curl из пода namespace B — должен fail.
---
## Задача 3: fission_simple_function Terraform ресурс (ПРИОРИТЕТ 3)
**Зачем**: сейчас юзер пишет 4 ресурса вместо одного. Это неудобно и error-prone.
**Ветка**: `feat/simple-function-resource` (создана, код не написан)
**Файл**: `terraform/provider/internal/resources/simple_function_resource.go`
**Целевой синтаксис для пользователя:**
```hcl
resource "fission_simple_function" "health" {
name = "health"
runtime = "nodejs"
code_dir = "./code/health"
url = "/ecom/health"
methods = ["GET"]
}
```
**Что провайдер делает внутри:**
1. Ищет существующий `fission_environment` для runtime → переиспользует или создаёт
2. Zip-архивирует `code_dir` → создаёт `fission_package` (с ESM wrap для nodejs)
3. Создаёт `fission_function`
4. Создаёт `fission_http_trigger` (если указан `url`)
**Атрибуты ресурса:**
```
name string — имя функции
runtime string — nodejs / python / go / ruby / perl / php
code_dir string — путь к директории с кодом
url string — HTTP route (опционально)
methods []string — ["GET","POST"] (опционально, default GET)
entrypoint string — имя точки входа (опционально, default "Handler")
min_scale int — минимальный масштаб (опционально, default 0)
max_scale int — максимальный масштаб (опционально, default 1)
ttl int — TTL в секундах (опционально)
```
**Computed атрибуты:**
```
invoke_url string — полный URL для вызова
status string — статус package (building / succeeded / failed)
```
**Особенности реализации:**
- nodejs: ESM wrapper (`export default { Handler }`) — проверить формат как в Console
- Go: нужен archive package (zip), не literal. BuildStatus polling.
- Идемпотентность: Read → если уже есть все 3 ресурса, не пересоздавать
- Delete: удалить trigger + function + package. Environment — только если unused.
**Регистрация**: добавить в `terraform/provider/internal/provider/provider.go` в `Resources()`
**Примеры**: обновить `examples/big-suite/` после реализации
**Тест**: `terraform apply` → `terraform plan` должен показать "no changes"
---
## Задача 4: Улучшение поверхности ошибок (ПРИОРИТЕТ 4)
**Зачем**: сейчас при провале specialization пользователь видит "timeout after 20s: function specialization likely failed" — не информативно.
**Что делать:**
### 4.1 Package build status в API response
При создании функции и при GET `/console/api/functions/:name` — тянуть и возвращать:
```json
{
"name": "myfunc",
"status": "building", // или "ready" / "failed"
"buildError": "compilation failed: undefined reference to..."
}
```
`buildError` брать из `package.status.info` (Fission заполняет это поле).
### 4.2 Specialization error в invoke response
Сейчас invoke возвращает timeout. Надо после timeout:
1. Проверить статус package → если `failed`, вернуть buildError
2. Проверить поды namespace → если CrashLoopBackOff, вернуть причину
3. Иначе — вернуть "specialization timeout, function pod not ready"
### 4.3 UI polling для build status
После создания Go-функции (или любой с builder) — polling `/console/api/functions/:name` каждые 3s, показывать статус:
- "Сборка..." → "Готово" / "Ошибка сборки: <text>"
**Файлы**: `console/main.go` (API), `console/ui/index.html` (UI polling)
---
## Задача 5: Cleanup и housekeeping (ПРИОРИТЕТ 5)
**Что сделать:**
### 5.1 Reaper для пустых namespace
Если namespace не имеет ни одной функции и не обращался больше N дней → удалить namespace + все ресурсы.
Параметр: `NAMESPACE_TTL_DAYS` (env var, default 30).
### 5.2 Orphan package cleanup
При удалении функции — проверить все packages namespace, удалить те, на которые нет ни одной функции.
Сейчас `cleanupEnvironmentIfUnused` есть, нужен аналог для packages.
### 5.3 Soft limit на функции на пользователя
`MAX_FUNCTIONS_PER_USER` (env var). При превышении → HTTP 429 с сообщением.
Защита от случайного создания 1000 функций в loop.
---
## Порядок выполнения (рекомендованный)
```
1. Задача 1 (ResourceQuota + LimitRange) — ~2-3 часа, высокий риск если не сделать
2. Задача 2 (NetworkPolicy) — ~1-2 часа, вместе с задачей 1
3. Задача 3 (fission_simple_function) — ~1-2 дня, основная фича
4. Задача 4 (ошибки) — ~4-6 часов, UX improvement
5. Задача 5 (cleanup) — ~3-4 часа, housekeeping
```
---
## Команды для нового чата
```bash
# Текущий образ
naeel/fission-console:v0.8.8
# Сборка нового образа (ОБЯЗАТЕЛЬНО сначала поменять тег в deploy/console.yaml!)
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 \
'cd ~/terra/fission && docker build -t naeel/fission-console:v0.8.9 -f console/Dockerfile console/ && docker push naeel/fission-console:v0.8.9'
# Deploy
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 \
'kubectl set image deployment/fission-console fission-console=naeel/fission-console:v0.8.9 -n fission'
# Тест после деплоя
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 \
'bash ~/terra/fission/tests_v2.sh 2>&1 | tail -5'
# Проверка quota (после задачи 1)
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 \
'kubectl get resourcequota,limitrange -n fission-c3fce59430e41b0f'
```
---
## Файлы проекта (ключевые)
| Файл | Назначение |
|------|-----------|
| `console/main.go` | Основная логика бэкенда |
| `console/ui/index.html` | Embedded UI |
| `console/deploy/console.yaml` | K8s deployment (тег образа) |
| `console/deploy/rbac/console-ns-manager.yaml` | RBAC для fission-console SA |
| `terraform/provider/internal/resources/` | Terraform ресурсы |
| `terraform/provider/internal/provider/provider.go` | Регистрация ресурсов |
| `tests_v2.sh` | Регресс-тесты (73 блока, все PASS) |
| `doc/thinking/2026-04-25-ai-opinions.md` | Мнения AI моделей |