Compare commits

...
Author SHA1 Message Date
“Naeel” 33bc765f99 fix(storagesvc): scan all namespaces in archivePruner
DefaultNSResolver().Snapshot() returns only namespaces registered via
AddNamespace(). Storagesvc does not listen to namespace events, so tenant
namespaces (fission-*) are never registered and their Package CRDs are
invisible to the pruner — causing all archives to be treated as orphans
and deleted.

Fix: use metav1.NamespaceAll to list Packages across all namespaces.
Remove unused pkg/utils import.

Deployed as naeel/fission-bundle:v1.23.1
2026-05-19 11:33:52 +04:00
“Naeel” 7265985309 fix(reconciler): health-check Active NS every 60s to restore deleted SA/RoleBindings
RunReconciler now runs two tickers:
- 30s: retry Failed namespaces (existing behavior)
- 60s: DispatchResync on Active namespaces; since registerNamespace is
  idempotent this is a no-op when SA/RoleBindings are intact and
  silently restores them if deleted

Fixes P1-A from integration test 2026-05-18: SA deleted from Active NS
was not being restored because reconciler only processed Failed NS.
2026-05-18 13:27:50 +04:00
“Naeel” 650616b464 build: fix base image, bump to v1.22.1, update test plan 2026-05-18 12:29:08 +04:00
“Naeel” c8ced44068 doc: add integration test plan for P1/P2 (namespace lifecycle hardening) 2026-05-18 12:06:47 +04:00
“Naeel” 491f0aee43 doc(audit): mark AdoptExistingResources race as CLOSED (2a7d6101)
Updated FORENSIC_ARCHITECTURE_AUDIT.md:
- Status table: AdoptExistingResources race  ЗАКРЫТ (2a7d6101)
- §1.3: rewritten as ЗАКРЫТ with end-to-end fix description
- Fragile Components: Manual Adoption → закрыто
- Risky Decisions table: Manual Adoption 
- Production-Grade summary: AdoptExistingResources race закрыт
- §2 Stuck Failed Accumulation:  ЗАКРЫТ
- §2 AdoptExistingResources Race:  ЗАКРЫТ
- §3 P2:  ЗАКРЫТ
- §5 Sharded mutex verdict: updated (race закрыт)
2026-05-18 11:45:18 +04:00
“Naeel” 2a7d6101b1 fix(executor): P2 — pre-register managed NS before adopt/cleanup (closes AdoptExistingResources race)
Problem:
  executor starts → AdoptExistingResources + CleanupOldExecutorObjects run
  against utils.DefaultNSResolver().Snapshot() which returns ONLY static NS
  from FISSION_RESOURCE_NAMESPACES. Managed (labeled) namespaces are
  registered later, asynchronously, by StartNSWatcher.

  Result:
  - Pods from a previous executor in managed NS are never adopted
    (no instanceID patch) → poolmgr creates new pool pods → cold start
    for first request after executor restart.
  - Old executor objects (RS/deployments) in managed NS accumulate
    without being cleaned up (resource leak).

Fix:
  Add multitenant.PreRegisterManagedNamespaces(ctx, logger, kubernetesClient)
  called synchronously in executor.go BEFORE the adopt/cleanup goroutines.

  The function does a single Namespaces.List with label
  fission.io/managed=true and calls DefaultNSResolver().AddNamespace() for
  each result. This is idempotent with the later watcher AddFunc calls.

  Failure is non-fatal: a warning is logged and startup proceeds with
  static NS only (safe degraded mode).

  After this call DefaultNSResolver().Snapshot() includes managed NS, so:
  - AdoptExistingResources patches old pods in managed NS with new instanceID
  - CleanupOldExecutorObjects removes stale objects from managed NS
  - GetReaperNamespace() returns the full tenant NS set

Files:
  pkg/executor/multitenant/ns_watcher.go — PreRegisterManagedNamespaces()
  pkg/executor/executor.go               — call before adopt/cleanup
2026-05-18 11:42:48 +04:00
“Naeel” f5b57173f5 doc(audit): mark stuck-failed as CLOSED after 919e8439 (RunReconciler + error propagation)
Updated FORENSIC_ARCHITECTURE_AUDIT.md to reflect the fix from commit 919e8439:

Status table:
- 'Stuck-failed namespace без auto-recovery':  ОТКРЫТ →  ЗАКРЫТ (919e8439)
- 'No Explicit State Machine':  ОТКРЫТ → ⚠️ СМЯГЧЕНО (stuck-failed закрыт;
  явная state machine остаётся в backlog)

Sections updated:
- §1.4: полное описание что было (void-функции, мёртвый reconciler) и что
  сделано (error propagation chain, end-to-end flow retry)
- Fragile Components: Stuck Failed Phase — вычеркнуто как закрытое
- Risky Decisions table: No Explicit State Machine → частично закрыто
- Lifecycle Management: добавлено что auto-recovery работает через RunReconciler
- Operational Burden: убрано упоминание stuck-failed как активной проблемы
- Maintainability/Production-Grade: обновлены под текущее состояние
- §2 Stuck Failed Accumulation:  ЗАКРЫТ
- §3 P1 Reconcile-очередь:  ЗАКРЫТ
- §5 Sharded mutex вердикт: убрано упоминание stuck-failed
2026-05-18 11:32:13 +04:00
“Naeel” 919e84396c fix(reconciler): propagate SA/executor errors to NamespaceManager so failed NSes are retried
Problem
-------
The namespace reconciler (RunReconciler, added previously) retries namespaces
in NamespacePhaseFailed every 30s by calling DispatchResync. But the phase
could never actually reach NamespacePhaseFailed for the executor component
because the executor's NamespaceSubscriber always returned nil — swallowing
any SA-provisioning or informer-init errors. The reconciler was dead code for
the executor path.

Root cause chain
----------------
1. setupSAAndRoleBindings() — void, errors only logged internally.
2. EnsureNamespaceSA()      — void, just called setupSAAndRoleBindings.
3. registerNamespace()      — void, errors from both functions lost.
4. Executor AddFunc/ResyncFunc — always returned nil to dispatch().
5. dispatch() marks parts Active unconditionally   → NamespacePhaseFailed
   is never triggered for executor   → RunReconciler never fires for executor.

Consequence: if EnsureNamespaceSA failed (transient k8s 503, RBAC webhook
timeout, etc.) the namespace appeared Active in the manager but the fetcher
ServiceAccount was missing. Pool pods would CrashLoopBackOff on every call
to that namespace until a full process restart.

Changes
-------
pkg/utils/serviceaccount.go
  - setupSAAndRoleBindings: void → error. Returns the first k8s API error
    so callers can decide whether to retry.
  - runSACheck: ignores the error with _ = (same behaviour as before, it's
    a periodic background loop that already logs internally).
  - EnsureNamespaceSA: void → error, propagates setupSAAndRoleBindings.
    Updated godoc to explain the retry contract.

pkg/executor/multitenant/ns_watcher.go
  - registerNamespace: void → error.
    * EnsureNamespaceSA error → wrapped as 'EnsureNamespaceSA: ...' and returned.
    * registerExecutorTypes error → wrapped as 'registerExecutorTypes: ...' and returned.
    * Success log line only emitted when both succeed.
  - Added 'fmt' import for error wrapping.

pkg/executor/multitenant/namespace_subscriber.go
  - AddFunc:    return registerNamespace(...) instead of ignoring its error.
  - ResyncFunc: same — plus a comment explaining why it is safe to call
    registerNamespace again (SA creation is idempotent, executor-type
    AddNamespace guards against duplicate informer creation).

pkg/utils/namespace_manager.go
  - RunReconciler interface signature: added *zap.Logger parameter.
    Callers pass the component logger so retries are visible in prod logs.
  - RunReconciler implementation:
    * Accepts logger; falls back to zap.NewNop() if nil.
    * Skips the tick entirely when no failed namespaces are found (no log spam).
    * Logs 'retrying failed namespaces' with count + list when found.
    * Logs per-namespace 'dispatching resync'.
    * Logs 'resync succeeded' or 'resync still failing, will retry' with error.
  - RunManagedNamespaceWatcher: passes logger to RunReconciler.

End-to-end flow after this fix
-------------------------------
1. EnsureNamespaceSA fails (k8s 503).
2. registerNamespace returns error.
3. Executor AddFunc returns error.
4. dispatch() calls MarkPartFailed("executor") → deriveNamespacePhase →
   NamespacePhaseFailed.
5. RunReconciler tick (30s) finds the namespace → DispatchResync →
   registerNamespace called again → EnsureNamespaceSA (idempotent) →
   if API recovered: success → MarkPartActive → NamespacePhaseActive.
6. Log line 'namespace reconciler: resync succeeded' confirms recovery.

Backward compatibility
----------------------
- NamespaceManager interface: RunReconciler gained a *zap.Logger param.
  There is exactly one implementation (inMemoryNamespaceManager) and one
  call site (RunManagedNamespaceWatcher). No external mocks.
- EnsureNamespaceSA: callers outside this codebase (if any) that ignore
  the error will still compile (Go allows ignoring return values).
- All 26 affected tests pass: go test ./pkg/utils/... ./pkg/executor/...
  ./pkg/buildermgr/... ./pkg/router/...
2026-05-18 11:10:46 +04:00
“Naeel” 28c45e65aa doc: update forensic audit to reflect namespace lifecycle hardening (2026-05-18) 2026-05-18 09:13:35 +04:00
“Naeel” 695bfb74d4 doc: impl notes for namespace lifecycle hardening (2026-05-18) 2026-05-18 09:08:35 +04:00
“Naeel” 4eedf95f5c fix(namespace): executor/router/buildermgr RemoveNamespace + per-NS informer lifecycle
- Add RemoveNamespace(ctx, ns) to executortype.ExecutorType interface
- Implement RemoveNamespace in poolmgr, newdeploy, container executor types
- Add per-namespace context cancellation (nsCancels map) in all three types so
  informer factories are stopped when namespace is removed (fixes goroutine leak)
- Add PoolPodController.RemoveNamespace to clear envLister/podLister maps
- Add deregisterNamespace() in executor multitenant subscriber
- Switch executor/router/buildermgr watcher strategy from TrackOnly to DispatchRemove
  so RemoveFunc is called when fission.io/managed label is removed
- Add RemoveFunc to executor/router/buildermgr namespace subscribers
- Add RemoveNamespace to environmentWatcher and packageWatcher with per-NS cancel
- Add RemoveNamespace to HTTPTriggerSet: cancels informers, removes from maps, calls syncTriggers
- Fix ns_watcher_test.go fakeExecutorType to implement new RemoveNamespace method

Fixes:
- Executor dedup gap: re-added namespace was silently skipped (envLister/deplLister still present)
- Goroutine/FD leak: old informer factories ran forever after namespace removal
- Router stale routes: HTTPTriggers for removed namespace stayed in routing table
2026-05-18 09:04:13 +04:00
“Naeel” 3b93c5dc8b fix(namespace): harden lifecycle — RemoveNamespace, parallel dispatch, reconciler
- DefaultNSResolver.RemoveNamespace(): removes NS from global map on label removal
  so Snapshot() and idleObjectReaper stop iterating deleted namespaces.
  Fixes class of dirty-state bugs when NS name is reused by new tenant.

- HandleWatcherNamespaceRemoval: call RemoveNamespace on both TrackOnly and
  DispatchRemove strategies — global resolver cleanup is always required.

- dispatch(): parallel subscriber execution via goroutine per subscriber +
  sync.WaitGroup. Reduces onboarding latency from O(N_subscribers × API_latency)
  to O(max(API_latency)). Safe: MarkPart* are internally mutex-protected.

- inMemoryNamespaceManager.RunReconciler(): 30s ticker scans for
  NamespacePhaseFailed records and retries via DispatchResync. Started
  automatically by RunManagedNamespaceWatcher. Fixes permanent stuck-failed
  state caused by transient k8s API errors.

Analysis source: FORENSIC_ARCHITECTURE_AUDIT.md §Deep Risk Analysis
2026-05-18 08:45:31 +04:00
“Naeel” 4c82285863 doc: add console integration section to porting guide 2026-05-15 08:04:46 +04:00
“Naeel” 728cc351b5 doc: add multitenant porting guide for upstream upgrade 2026-05-15 07:49:47 +04:00
“Naeel” 5d52c9ba94 test: add integration test for NSWatcher with fake Kubernetes client
TestStartManagedNamespaceWatcherIntegration проверяет полный маршрут
горячей регистрации namespace без real cluster:

1. RunManagedNamespaceWatcher запускается с k8sfake.NewSimpleClientset()
2. В fake client создаётся Namespace с label fission.io/managed=true
3. Kubernetes informer детектирует событие (без polling, через Watch)
4. SubscriberFuncs.OnNamespaceAdd вызывается
5. NamespaceManager содержит запись со статусом Active

Тест доказывает, что вся цепочка
  fake k8s event → informer → AddFunc → subscriber → manager
работает корректно без rolling restart процесса.

Также добавлен import metav1 в test file (требовался для CreateOptions).
2026-05-15 07:11:48 +04:00
“Naeel” 5f0ab79f00 doc: add multitenant architecture summary (2026-05-15)
Единый сводный документ, описывающий полную архитектуру мультитенантного Fission.
Заменяет необходимость читать 50+ пошаговых thinking-файлов.

Содержит:
- Причина и концепция решения
- Архитектурная карта изменений (ASCII diagram)
- Таблица ключевых файлов с ролями
- Инженерные решения: Snapshot API, NamespaceManager event bus,
  EnsureNamespaceSA, buildermgr dedup bug, router nil guard
- RBAC: что и почему (включая нетривиальные events:create и LSAR)
- Backward compatibility guarantees
- Описание test scenario (Layer 1, PASS=5)
- Порядок деплоя нового форка
- Направления дальнейшей работы
2026-05-15 07:08:32 +04:00
“Naeel” 4addf254cb chore: remove superseded executor-ns-watcher-rbac.yaml
Файл deploy/executor-ns-watcher-rbac.yaml был создан на раннем этапе работы
над мультитенантностью. Он содержал только partial RBAC (только executor,
без router и без SA-provisioner прав).

Файл полностью покрыт deploy/multitenant/rbac.yaml который содержит:
- fission-executor-ns-watcher: list/watch namespaces
- fission-router-ns-watcher: list/watch namespaces
- fission-executor-sa-provisioner: create SA/Role/RoleBinding в user NS

Старый файл нигде не referenced — ни в charts, ни в коде.
2026-05-15 07:06:58 +04:00
“Naeel” b5f8a9bf0e doc: add multi-tenant quick reference card
Краткий справочник команд и концепций мультитенантного Fission.
Содержит: жизненный цикл namespace, CLI команды, схему RBAC,
структуру URL функций, типичные сценарии использования.
2026-05-15 06:59:19 +04:00
“Naeel” b2efefd75a doc: add multi-tenant Fission Console API guide
Полное руководство по REST API мультитенантного Fission Console.
Описывает все эндпоинты: создание namespace (tenant), деплой функций,
управление environment, триггеры, пакеты.
Актуально для нашего форка с мультитенантностью.
2026-05-15 06:59:14 +04:00
“Naeel” 5988ced1e2 chore: add GitHub Copilot project rules
Добавлены файлы правил для GitHub Copilot:
- .github/copilot-instructions.md — краткие правила поведения ИИ в проекте:
  отвечать кратко, не трогать рабочий код без явного указания, rsync на ВМ
  после каждого изменения, git только локально.
- .github/pravila.md — расширенные правила проекта: порядок работы с SSH,
  запреты на групповое удаление, правила docker build и деплоя.
2026-05-15 06:59:07 +04:00
“Naeel” e3928e1d4d chore: ignore *.token files
Token files (mgmt.token и подобные) не должны попадать в репозиторий.
Добавлено правило *.token в .gitignore.
2026-05-15 06:59:00 +04:00
36 changed files with 3517 additions and 115 deletions
+61
View File
@@ -0,0 +1,61 @@
# Правила
## ⛔ ОТВЕЧАТЬ КРАТКО — АБСОЛЮТНОЕ ПРАВИЛО
- Вопрос → короткий ответ → СТОП.
- Ничего лишнего.
- Код — только по запросу.
## ⛔⛔⛔ ВОПРОС = СТОП
**Если в сообщении есть вопрос в ЛЮБОЙ форме** ("так ?", "верно ?", "почему ?", "как ?", "так же ?" и т.д.):
1. ТОЛЬКО ответить на вопрос
2. ОСТАНОВИТЬСЯ
3. ЖДАТЬ следующей команды
**ЗАПРЕЩЕНО** начинать работу, писать код, запускать команды — без явного "делай".
1. **⛔⛔⛔ АБСОЛЮТНЫЙ ЗАПРЕТ: не трогать и не читать рабочий код с целью подготовки к правке — без ПРЯМОГО указания "делай". Даже чтение файлов перед правкой — СТОП, сначала разрешение.**
2. Файлы редактируются локально:
~/fission-src (текущая рабочая папка)
После ЛЮБЫХ изменений ОБЯЗАТЕЛЬНО синхронизировать на ВМ командой:
rsync -az \
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
~/fission-src/ \
naeel@5.172.178.213:~/terra/fission-src/
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission-src
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ:
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
- не выполнять инфраструктурные команды локально
- не открывать интерактивные сессии
- не делать цепочки без необходимости
4. Перед запуском команд ОБЯЗАТЕЛЬНО убедиться, что синхронизация выполнена.
5. ЗАПРЕЩЕНО:
- откатывать код
- менять версии
- ломать рабочее состояние
6. После каждого исправления:
- git add/commit ЛОКАЛЬНО (в ~/fission-src)
- затем синхронизация (rsync) на ВМ
## ⛔⛔⛔ ДЕЛАТЬ ТОЛЬКО ЧТО ПРЯМО ПРИКАЗАНО
**АБСОЛЮТНЫЙ ЗАПРЕТ на додумывание:**
- Не расширять масштаб работы
- Не выполнять "логичные следующие шаги"
- Не инициировать дополнительные операции
- Не делать ничего кроме того что сказано
**Пример (2026-05-01):**
- Приказано: "собери"
- Сделано: ✓ собрал образы v1.3.17 и v0.1.2
- СТОП — жду команды дальше
- **ЗАПРЕЩЕНО:** обновлять манифесты, заливать образы, применять на кластер, запускать тесты
**Исключение:** только если приказ явно включает цепочку ("собери И залей И тесты")
+100
View File
@@ -0,0 +1,100 @@
# Правила работы агента
## ⛔⛔⛔ DOCKER — ОБЯЗАТЕЛЬНЫЙ ПОРЯДОК ПЕРЕД КАЖДЫМ BUILD
1. УВЕЛИЧИТЬ ТЕГ в `console/deploy/console.yaml` (vX.Y.Z → vX.Y.Z+1)
2. rsync на ВМ
3. ПРОВЕРИТЬ что файлы на ВМ новые (grep ключевой строки)
4. docker build с НОВЫМ тегом
5. docker push с НОВЫМ тегом
6. kubectl apply (не rollout restart — apply подтягивает новый тег)
**НИКОГДА не делать `docker build` со старым тегом — под не перетянет образ (imagePullPolicy: IfNotPresent)**
## Файловая система (актуально)
1. Все файлы редактируются локально: `~/fission-src` (текущая рабочая папка)
2. После любых изменений — обязательно rsync на ВМ:
rsync -az \
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
~/fission-src/ \
naeel@5.172.178.213:~/terra/fission-src/
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission-src
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ
5. Перед запуском любой команды на ВМ обязательно убедиться, что синхронизация (rsync) выполнена
6. SCP, sshfs, remote_dev и маунты больше НЕ используются
7. Только rsync для синхронизации
Пример:
1. Редактируешь локально (~/fission-src)
2. rsync на ВМ
3. Выполняешь команды через SSH на ВМ
## SSH
Все команды — только через SSH на ВМ. Локально — только читать и редактировать файлы.
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
```
Запрещено локально: `go`, `docker`, `kubectl`, `helm`, `terraform`, `curl/wget`, `git push/pull`, любые скрипты проекта.
## Документация
- `doc/thinking/` — лог рассуждений агента (обязательно)
- `doc/progress.md` — трекер задач
- Старые файлы `doc/` не перезаписывать — новое в новых файлах с датой
## Git
- Git — ТОЛЬКО ЛОКАЛЬНО в `~/fission-src`. НИКОГДА через SSH на VM.
- Разрешены ТОЛЬКО две операции: `git commit` и `git push`.
- ЗАПРЕЩЕНО: git pull, git fetch, git rebase, git merge, git reset, git stash, git checkout — что угодно кроме commit и push.
- Если push отклонён — СТОП, доложить пользователю. Не лезть в pull/merge/rebase самостоятельно.
Версионирование тегами: `vMAJOR.MINOR.PATCH`
- Patch — любое изменение кода
- Minor — новая фича / компонент
- Major — breaking change
```bash
git tag vX.Y.Z && git push origin vX.Y.Z
```
## ⛔ ТЕРМИНАЛЬНЫЙ БУФЕР — НИКОГДА НЕ ЧИТАТЬ СТАРЫЙ
**АБСОЛЮТНОЕ ПРАВИЛО:**
- get_terminal_output из старых сессий — МУСОР. Там старые прогоны.
- Всегда запускать новую команду через SSH и читать её вывод напрямую.
- НИКОГДА не читать буфер терминала из предыдущей сессии как актуальные данные.
- Актуальный результат — только из команды, которая была запущена СЕЙЧАС.
## ⛔ ДОКУМЕНТАЦИЯ ТЕСТ-ПРОГОНОВ — В РЕАЛЬНОМ ВРЕМЕНИ
**Правила:**
1. Перед запуском `run_all.sh` — создать файл `test-results/YYYY-MM-DD_HH-MM.log` и записать в него метку времени и что запускается.
2. Запускать `run_all.sh 2>&1 | tee ~/terra/fission-src/test-results/YYYY-MM-DD_HH-MM.log` — вывод пишется сразу в файл и отображается в терминале.
3. После завершения — rsync лога локально. Лог остаётся как документация.
4. Папка `test-results/` в репозитории — `.gitignore` не добавлять, логи коммитить.
**Формат запуска:**
```bash
LOG="test-results/$(date +%Y-%m-%d_%H-%M).log"
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 \
"bash ~/terra/fission-src/scripts/run_all.sh 2>&1 | tee ~/terra/fission-src/${LOG}"
rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no" \
naeel@5.172.178.213:~/terra/fission-src/test-results/ ~/fission-src/test-results/
```
**Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.**
## Поведение агента
- **⛔⛔⛔ АБСОЛЮТНЫЙ ЗАПРЕТ: не читать и не трогать код с целью подготовки к правке — без прямого "делай". Даже чтение файлов перед правкой — СТОП, сначала разрешение.**
- Не трогать рабочий код без явного указания
- Не делать НИЧЕГО сверх того, о чём явно приказали — ни git-команд, ни rebase, ни дополнительных шагов
- Если для продолжения нужен выбор — СПРОСИТЬ разрешения, не делать самостоятельно
- Деструктивные операции (`kubectl delete`, `rm -rf`, `terraform destroy` и др.) — только после явного подтверждения с указанием конкретных объектов
- Отвечать кратко, без вступлений, извинений, благодарностей и прочей воды
+2
View File
@@ -20,6 +20,8 @@ environments/php7/vendor/
*.tfstate
*.backup
*.token
# Common backup files
*.swp
*.bak
+1 -1
View File
@@ -1,3 +1,3 @@
FROM cgr.dev/chainguard/static:latest@sha256:a301031ffd4ed67f35ca7fa6cf3dad9937b5fa47d7493955a18d9b4ca5412d1a
FROM gcr.io/distroless/static-debian12:nonroot
COPY fission-bundle /
ENTRYPOINT ["/fission-bundle"]
+1 -1
View File
@@ -25,7 +25,7 @@ image: fission/fission-bundle
## It is also used by the chart to identify version of the few more images apart from fission-bundle.
## Keep it empty for using latest tag.
##
imageTag: v1.22.0
imageTag: v1.22.1
## pullPolicy represents the pull policy to use for images in the chart.
##
-26
View File
@@ -1,26 +0,0 @@
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: fission-executor-ns-watcher
labels:
app: fission-executor
rules:
- apiGroups: [""]
resources: ["namespaces"]
verbs: ["list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: fission-executor-ns-watcher
labels:
app: fission-executor
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: fission-executor-ns-watcher
subjects:
- kind: ServiceAccount
name: fission-executor
namespace: fission
@@ -0,0 +1,567 @@
# Namespace Lifecycle Hardening — Implementation Notes
## Дата: 2026-05-18
## Ветка: `fix/namespace-lifecycle-hardening`
## Коммиты: `3b93c5dc` (предыдущая сессия) → `4eedf95f` (эта сессия)
---
## 1. Контекст: что было сделано до этой сессии
### Предыдущие правки (коммит `3b93c5dc`)
1. **`RemoveNamespace(ns string) bool`** — добавлен в `NamespaceResolver` (`pkg/utils/namespace.go`).
`HandleWatcherNamespaceRemoval` теперь вызывает его при любой стратегии, очищая глобальный resolver. Это исправляет дедупликацию router/buildermgr при re-add NS.
2. **Параллельный `dispatch()`**`pkg/utils/namespace_manager.go`: заменён последовательный обход подписчиков на параллельный с `sync.WaitGroup`. Исправляет 30-минутное окно, когда HTTPTrigger не видел namespace из-за того что обход был последовательным.
3. **`RunReconciler()`** — добавлен в `NamespaceManager` interface и реализован в `inMemoryNamespaceManager`. Каждые 30 секунд сканирует namespace-ы в фазе `NamespacePhaseFailed` и вызывает `DispatchResync`. Исправляет постоянно stuck-failed namespace при транзиентных k8s API ошибках.
### Что оставалось нерешённым (из аудита)
Три проблемы, зафиксированные в `FORENSIC_ARCHITECTURE_AUDIT.md`:
**Проблема 1 — Executor dedup gap (Critical)**
При удалении NS и повторном добавлении executor молча пропускал его.
Причина: `gpm.poolPodC.envLister[ns]` и `deploy.deplLister[ns]` проверялись как дедупликация в `AddNamespace`, но никогда не очищались при удалении NS.
Результат: повторно добавленный namespace не получал informers в executor → функции не запускались.
**Проблема 2 — Goroutine/FD leak (High)**
При удалении NS старые informer factories продолжали работать (goroutines, file descriptors, LIST-запросы к k8s API каждые 30 минут).
Причина: informers запускались с `ctx.Done()` родительского контекста всего процесса, без механизма per-NS остановки.
**Проблема 3 — Router stale routes (Medium)**
После удаления NS router продолжал держать HTTPTrigger routes для этого namespace.
Причина: `triggerInformer[ns]` и `funcInformer[ns]` не чистились, `syncTriggers()` не вызывался.
---
## 2. Анализ перед реализацией
### 2.1 Чтение интерфейса ExecutorType
Файл: `pkg/executor/executortype/executortype.go`
```go
// До правки — нет RemoveNamespace
AddNamespace(ctx context.Context, ns string, mgr manager.Interface) error
}
```
Подтверждено: ни `grep`, ни LSP не нашли `RemoveNamespace` в executor types.
### 2.2 Анализ механизма дедупликации по каждому executor type
**poolmgr** (`gpm.go` строка 807):
```go
if _, ok := gpm.poolPodC.envLister[ns]; ok {
return nil // already registered
}
```
Деdup через `PoolPodController.envLister[ns]` — локальная карта, не связана с глобальным resolver.
**newdeploy** (`newdeploymgr.go` строка 917):
```go
if _, ok := deploy.deplLister[ns]; ok {
return nil // already registered
}
```
Деdup через `deploy.deplLister[ns]`.
**container** (`containermgr.go` строка 805):
```go
if _, ok := caaf.deplLister[ns]; ok {
return nil // already registered
}
```
Деdup через `caaf.deplLister[ns]`.
**router** (`httpTriggers.go` строка 461):
```go
if !utils.DefaultNSResolver().AddNamespace(ns) {
return nil // already registered
}
```
Деdup через глобальный resolver — **уже починен** предыдущим коммитом (`RemoveNamespace` в resolver).
**buildermgr envwatcher** (`envwatcher.go` строка 506):
```go
if _, exists := envw.envWatchInformer[ns]; exists {
return
}
```
Деdup через `envw.envWatchInformer[ns]`.
**buildermgr pkgwatcher** (`pkgwatcher.go` строка 337):
```go
if _, exists := pkgw.pkgInformer[ns]; exists {
return
}
```
Деdup через `pkgw.pkgInformer[ns]`.
### 2.3 Анализ стратегии удаления
`NewDefaultManagedNamespaceWatcherConfig` создаёт конфиг с `RemovalStrategy: NamespaceRemovalStrategyTrackOnly`.
При `TrackOnly``HandleWatcherNamespaceRemoval` вызывает `DefaultNSResolver().RemoveNamespace()` (наш предыдущий фикс), но **не** вызывает `manager.DispatchRemove()``subscriber.OnNamespaceRemove()``RemoveFunc` не срабатывает.
Для вызова `RemoveFunc` нужна стратегия `DispatchRemove`.
### 2.4 Решение: per-namespace context cancellation
Informers запускаются через `factory.Start(ctx.Done())`. Стандартный способ остановить отдельный informer — отменить контекст, с которым он запущен.
**Решение:**
```go
nsCtx, nsCancel := context.WithCancel(ctx)
gpm.nsCancels[ns] = nsCancel
finformer.Start(nsCtx.Done()) // вместо ctx.Done()
```
При `RemoveNamespace`:
```go
if cancel, ok := gpm.nsCancels[ns]; ok {
cancel() // останавливает goroutines informer factories
delete(gpm.nsCancels, ns)
}
```
Это чисто и не требует изменения k8s client-go.
### 2.5 Mutex и thread-safety
Существующий код в executor types не защищает lister maps мьютексами. Записи в них происходят только при `AddNamespace` (из subscriber goroutine). Добавление `RemoveNamespace` добавляет ещё одну запись из той же goroutine. Race condition с event handlers (которые читают эти maps) — известное ограничение существующего дизайна, не добавляем мьютексы чтобы не выходить за рамки задачи.
`HTTPTriggerSet` уже имеет `informerMu sync.RWMutex` — его и используем в `RemoveNamespace` при удалении из `triggerInformer`/`funcInformer`.
---
## 3. Реализация — пошаговое описание
### Шаг 1: `pkg/executor/executortype/executortype.go`
Добавлен метод в `ExecutorType` interface:
```go
// RemoveNamespace deregisters a namespace from the executor, cancelling its
// informer goroutines and clearing dedup state so that a re-add works correctly.
// Called when a Namespace with label fission.io/managed=true is removed.
RemoveNamespace(ctx context.Context, ns string) error
```
**Почему:** Все три executor type реализуют этот интерфейс. Добавление в интерфейс гарантирует, что новый тип executor не забудет реализовать метод (компилятор поймает).
---
### Шаг 2: `pkg/executor/executortype/poolmgr/gpm.go`
**2a. Добавлено поле в struct:**
```go
// nsCancels holds per-namespace context cancel functions so informer
// factories started in AddNamespace can be stopped on RemoveNamespace.
nsCancels map[string]context.CancelFunc
```
**2b. Инициализация в `MakeGenericPoolManager`:**
```go
nsCancels: make(map[string]context.CancelFunc),
```
**2c. Изменение в `AddNamespace`:** вместо `ctx.Done()` передаём `nsCtx.Done()`:
```go
nsCtx, nsCancel := context.WithCancel(ctx)
gpm.nsCancels[ns] = nsCancel
finformer.Start(nsCtx.Done())
gpmInformer.Start(nsCtx.Done())
```
**2d. Новый метод `RemoveNamespace`:**
```go
func (gpm *GenericPoolManager) RemoveNamespace(ctx context.Context, ns string) error {
if ns == "" { return nil }
gpm.logger.Info("RemoveNamespace: cleaning up namespace (poolmgr)", ...)
if cancel, ok := gpm.nsCancels[ns]; ok {
cancel()
delete(gpm.nsCancels, ns)
}
delete(gpm.podLister, ns)
delete(gpm.podListerSynced, ns)
gpm.poolPodC.RemoveNamespace(ns) // очищает envLister/podLister в PoolPodController
return nil
}
```
---
### Шаг 3: `pkg/executor/executortype/poolmgr/poolpodcontroller.go`
Добавлен метод, очищающий lister maps в `PoolPodController`:
```go
func (p *PoolPodController) RemoveNamespace(ns string) {
delete(p.envLister, ns)
delete(p.envListerSynced, ns)
delete(p.podLister, ns)
delete(p.podListerSynced, ns)
p.logger.Info("PoolPodController.RemoveNamespace: cleared lister state", ...)
}
```
**Почему отдельный метод:** `PoolPodController` — отдельная структура внутри poolmgr. Доступ к её полям из `GenericPoolManager.RemoveNamespace` требовал бы либо экспорта полей, либо метода. Метод — чище.
---
### Шаг 4: `pkg/executor/executortype/newdeploy/newdeploymgr.go`
Аналогично gpm:
- Добавлен `nsCancels map[string]context.CancelFunc` в struct `NewDeploy`
- Инициализирован в `MakeNewDeploy`
- `AddNamespace` переключён на `nsCtx.Done()`
- Добавлен `RemoveNamespace` очищающий `deplLister`, `deplListerSynced`, `svcLister`, `svcListerSynced`
---
### Шаг 5: `pkg/executor/executortype/container/containermgr.go`
Аналогично. Struct `Container` получил `nsCancels`. `AddNamespace` использует `nsCtx.Done()`. `RemoveNamespace` очищает `deplLister`, `deplListerSynced`, `svcLister`, `svcListerSynced`.
---
### Шаг 6: `pkg/executor/multitenant/namespace_subscriber.go`
Добавлен `RemoveFunc` в `NamespaceSubscriberFuncs`:
```go
RemoveFunc: func(ctx context.Context, record utils.NamespaceRecord) error {
return deregisterNamespace(ctx, logger, record.Name, executorTypes)
},
```
`deregisterNamespace` итерирует все executor types и вызывает `et.RemoveNamespace(ctx, ns)`.
---
### Шаг 7: `pkg/executor/multitenant/ns_watcher.go`
**7a. Добавлен `deregisterNamespace`:**
```go
func deregisterNamespace(ctx, logger, ns, executorTypes) error {
var joinErr error
for _, et := range executorTypes {
if err := et.RemoveNamespace(ctx, ns); err != nil {
joinErr = errors.Join(joinErr, err)
}
}
logger.Info("multitenant.NSWatcher: deregistered namespace", ...)
return joinErr
}
```
**7b. Изменён `StartNSWatcher`:** стратегия `TrackOnly``DispatchRemove`:
```go
config := utils.NewDefaultManagedNamespaceWatcherConfig(...)
config.RemovalStrategy = utils.NamespaceRemovalStrategyDispatchRemove
```
**Почему:** Без `DispatchRemove` `RemoveFunc` подписчика никогда не вызывается. `TrackOnly` вызывает только `DefaultNSResolver().RemoveNamespace()` (что сделано в `HandleWatcherNamespaceRemoval`), но не диспетчирует событие подписчикам.
---
### Шаг 8: `pkg/buildermgr/envwatcher.go`
- Добавлен `nsCancels map[string]context.CancelFunc` в struct `environmentWatcher`
- Инициализирован в `makeEnvironmentWatcher` (там же где `envWatchInformer`)
- `AddNamespace` переключён на per-NS context:
```go
nsCtx, nsCancel := context.WithCancel(ctx)
envw.nsCancels[ns] = nsCancel
factory.Start(nsCtx.Done())
```
- Добавлен `RemoveNamespace(ns string)`:
```go
func (envw *environmentWatcher) RemoveNamespace(ns string) {
if cancel, ok := envw.nsCancels[ns]; ok { cancel(); delete(...) }
delete(envw.envWatchInformer, ns)
}
```
**Ошибка при первой попытке:** replace_string_in_file добавил `nsCancels` с тройным отступом (три таба вместо двух) и без закрывающего `}` struct literal — синтаксическая ошибка компиляции. Исправлено вторым вызовом replace.
---
### Шаг 9: `pkg/buildermgr/pkgwatcher.go`
Аналогично envwatcher:
- `nsCancels` в struct `packageWatcher`
- Инициализация в `makePackageWatcher`
- `AddNamespace` → per-NS ctx для `fissionFactory.Start()` и `podFactory.Start()`
- `RemoveNamespace(ns string)` очищает `pkgInformer[ns]`, `podInformer[ns]`
---
### Шаг 10: `pkg/buildermgr/namespace_subscriber.go`
Добавлены два новых интерфейса:
```go
type builderEnvNamespaceRemover interface {
RemoveNamespace(ns string)
}
type builderPkgNamespaceRemover interface {
RemoveNamespace(ns string)
}
```
Добавлен `RemoveFunc`:
```go
RemoveFunc: func(ctx context.Context, record utils.NamespaceRecord) error {
deregisterBuilderNamespace(record.Name, envw, pkgw)
return nil
},
```
`deregisterBuilderNamespace` через type assertion вызывает `RemoveNamespace` если интерфейс реализован:
```go
func deregisterBuilderNamespace(namespace string, envw, pkgw) {
utils.DefaultNSResolver().RemoveNamespace(namespace)
if r, ok := envw.(builderEnvNamespaceRemover); ok { r.RemoveNamespace(namespace) }
if r, ok := pkgw.(builderPkgNamespaceRemover); ok { r.RemoveNamespace(namespace) }
}
```
**Почему type assertion:** `builderEnvNamespaceAdder` — интерфейс-параметр функции `NewNamespaceSubscriber`. Вместо добавления `RemoveNamespace` в существующий интерфейс (что сломало бы тестовые фейки) используем опциональный интерфейс через type assertion.
---
### Шаг 11: `pkg/buildermgr/ns_watcher.go`
Стратегия изменена на `DispatchRemove` аналогично executor.
---
### Шаг 12: `pkg/router/httpTriggers.go`
**12a. Добавлен `nsCancels` в struct:**
```go
// nsCancels holds per-namespace context cancel functions for informer lifecycle.
nsCancels map[string]context.CancelFunc
```
**12b. Инициализация в `makeHTTPTriggerSet`:**
```go
nsCancels: make(map[string]context.CancelFunc),
```
**12c. Изменён `AddNamespace`:** per-NS ctx:
```go
nsCtx, nsCancel := context.WithCancel(ctx)
ts.nsCancels[ns] = nsCancel
factory.Start(nsCtx.Done())
k8sCache.WaitForCacheSync(nsCtx.Done(), ...) // тоже nsCtx
```
**12d. Новый метод `RemoveNamespace`:**
```go
func (ts *HTTPTriggerSet) RemoveNamespace(ns string) {
if cancel, ok := ts.nsCancels[ns]; ok { cancel(); delete(...) }
ts.informerMu.Lock()
delete(ts.triggerInformer, ns)
delete(ts.funcInformer, ns)
ts.informerMu.Unlock()
ts.syncTriggers() // немедленно перестраивает routing table без удалённого NS
}
```
**Почему `informerMu.Lock()`:** `HTTPTriggerSet` уже имеет `informerMu sync.RWMutex` для защиты `triggerInformer`/`funcInformer`. Используем его — не добавляем новые мьютексы.
---
### Шаг 13: `pkg/router/namespace_subscriber.go`
Добавлен `routerNamespaceRemover` interface и `RemoveFunc`:
```go
type routerNamespaceRemover interface {
RemoveNamespace(ns string)
}
RemoveFunc: func(ctx context.Context, record utils.NamespaceRecord) error {
if r, ok := ts.(routerNamespaceRemover); ok {
r.RemoveNamespace(record.Name)
}
return nil
},
```
---
### Шаг 14: `pkg/router/ns_watcher.go`
Стратегия → `DispatchRemove`.
---
### Шаг 15: Тест-фейк `pkg/executor/multitenant/ns_watcher_test.go`
`fakeExecutorType` не реализовывал новый метод → ошибка компиляции:
```
*fakeExecutorType does not implement executortype.ExecutorType (missing method RemoveNamespace)
```
Добавлена заглушка:
```go
func (f *fakeExecutorType) RemoveNamespace(ctx context.Context, ns string) error { return nil }
```
---
## 4. Результат компиляции и тестов
```
go build ./pkg/... ./cmd/... → нет вывода (успех)
go test ./pkg/utils/...
./pkg/executor/...
./pkg/buildermgr/...
./pkg/router/...
ok github.com/fission/fission/pkg/utils
ok github.com/fission/fission/pkg/executor/executortype/newdeploy
ok github.com/fission/fission/pkg/executor/executortype/poolmgr
ok github.com/fission/fission/pkg/executor/fscache
ok github.com/fission/fission/pkg/executor/multitenant
ok github.com/fission/fission/pkg/executor/util
ok github.com/fission/fission/pkg/buildermgr
ok github.com/fission/fission/pkg/router
```
---
## 5. Схема потока при удалении NS (после всех правок)
```
k8s: Namespace label fission.io/managed=true удалён/NS удалён
ManagedNamespaceWatcher (DispatchRemove стратегия)
├─► HandleWatcherNamespaceRemoval()
│ DefaultNSResolver().RemoveNamespace(ns) ← сброс глобального guard
│ manager.DispatchRemove(ctx, ns)
inMemoryNamespaceManager.DispatchRemove()
├─► goroutine: subscriber[executor].OnNamespaceRemove(record)
│ deregisterNamespace(ctx, logger, ns, executorTypes)
│ gpm.RemoveNamespace(ctx, ns)
│ nsCancel() ← останавливает informer goroutines
│ delete(podLister[ns])
│ delete(podListerSynced[ns])
│ poolPodC.RemoveNamespace(ns)
│ delete(envLister[ns])
│ delete(envListerSynced[ns])
│ delete(podLister[ns])
│ delete(podListerSynced[ns])
│ deploy.RemoveNamespace(ctx, ns)
│ nsCancel()
│ delete(deplLister[ns])
│ delete(deplListerSynced[ns])
│ delete(svcLister[ns])
│ delete(svcListerSynced[ns])
│ container.RemoveNamespace(ctx, ns)
│ nsCancel()
│ delete(deplLister[ns])
│ delete(svcLister[ns])
├─► goroutine: subscriber[buildermgr].OnNamespaceRemove(record)
│ deregisterBuilderNamespace(ns, envw, pkgw)
│ DefaultNSResolver().RemoveNamespace(ns) ← повторно (безопасно)
│ envw.RemoveNamespace(ns)
│ nsCancel()
│ delete(envWatchInformer[ns])
│ pkgw.RemoveNamespace(ns)
│ nsCancel()
│ delete(pkgInformer[ns])
│ delete(podInformer[ns])
└─► goroutine: subscriber[router].OnNamespaceRemove(record)
ts.RemoveNamespace(ns)
nsCancel() ← останавливает triggerInf/funcInf goroutines
informerMu.Lock()
delete(triggerInformer[ns])
delete(funcInformer[ns])
informerMu.Unlock()
syncTriggers() ← немедленно убирает routes для удалённого NS
```
---
## 6. Что НЕ было реализовано и почему
**`FunctionServiceCache.DeleteByNamespace(ns string)`** — не реализовано.
Причина: `idleObjectReaper` периодически вызывает `IsValid()` для всех записей. Для удалённого NS k8s API возвращает 404/403 → `IsValid()` вернёт `false` → запись будет удалена reaperом естественным образом. Это создаёт несколько минут "грязных" записей и 404 ошибки в логах, но не влияет на корректность: для удалённого NS новые запросы не придут (router очистил routes), а reaper уберёт старые записи.
Реализация `DeleteByNamespace` потребовала бы добавления namespace-индекса в `byFunction`/`byAddress`/`byFunctionUID` кэшах (нетривиально), или дорогого линейного прохода по всем записям. Не было делать без явного запроса.
---
## 7. Затронутые файлы (17 изменённых)
| Файл | Тип изменения |
|------|---------------|
| `pkg/executor/executortype/executortype.go` | +метод в interface |
| `pkg/executor/executortype/poolmgr/gpm.go` | +поле nsCancels, modify AddNamespace, +RemoveNamespace |
| `pkg/executor/executortype/poolmgr/poolpodcontroller.go` | +RemoveNamespace |
| `pkg/executor/executortype/newdeploy/newdeploymgr.go` | +поле nsCancels, modify AddNamespace, +RemoveNamespace |
| `pkg/executor/executortype/container/containermgr.go` | +поле nsCancels, modify AddNamespace, +RemoveNamespace |
| `pkg/executor/multitenant/namespace_subscriber.go` | +RemoveFunc |
| `pkg/executor/multitenant/ns_watcher.go` | +deregisterNamespace, DispatchRemove |
| `pkg/executor/multitenant/ns_watcher_test.go` | +RemoveNamespace в fakeExecutorType |
| `pkg/buildermgr/namespace_subscriber.go` | +интерфейсы remover, +RemoveFunc, +deregisterBuilderNamespace |
| `pkg/buildermgr/envwatcher.go` | +nsCancels, modify AddNamespace, +RemoveNamespace |
| `pkg/buildermgr/pkgwatcher.go` | +nsCancels, modify AddNamespace, +RemoveNamespace |
| `pkg/buildermgr/ns_watcher.go` | DispatchRemove |
| `pkg/router/httpTriggers.go` | +nsCancels, modify AddNamespace, +RemoveNamespace |
| `pkg/router/namespace_subscriber.go` | +routerNamespaceRemover, +RemoveFunc |
| `pkg/router/ns_watcher.go` | DispatchRemove |
| `doc/FORENSIC_ARCHITECTURE_AUDIT.md` | перемещён из корня (git rename) |
| `doc/console-compat-2026-05-15.md` | создан (отдельная задача) |
---
## 8. Ошибки в процессе
### Ошибка 1: Синтаксическая ошибка в envwatcher.go
**Что случилось:** При попытке заменить блок инициализации struct добавился `nsCancels:` с тройным отступом и без закрывающей `}`:
```
// Стало (неверно):
enableOwnerReferences: utils.IsOwnerReferencesEnabled(),
nsCancels: make(map[string]context.CancelFunc),
err := envWatcher.EnvWatchEventHandlers(ctx)
// ← пропущена } закрывающая struct literal
```
**Причина:** replace_string_in_file не нашёл точное совпадение с нужным whitespace и применил замену частично некорректно.
**Исправление:** второй вызов replace_string_in_file с правильным контекстом (включая соседние строки для однозначного совпадения).
**Вывод компилятора:**
```
pkg/buildermgr/envwatcher.go:117:6: syntax error: unexpected := in composite literal; possibly missing comma or }
```
### Ошибка 2: Тест-фейк не реализует интерфейс
**Что случилось:** После добавления `RemoveNamespace` в interface `ExecutorType`, тест `ns_watcher_test.go` не компилировался:
```
*fakeExecutorType does not implement executortype.ExecutorType (missing method RemoveNamespace)
```
**Исправление:** добавлена заглушка в `fakeExecutorType`.
---
## 9. Инварианты безопасности
1. `nsCancel()` идемпотентен: повторный вызов не паникует (context package гарантирует это)
2. `RemoveNamespace("")` защищён early return во всех реализациях
3. `deregisterBuilderNamespace` через type assertion — безопасно если интерфейс не реализован (просто пропускает)
4. `routerNamespaceRemover` через type assertion в router subscriber — аналогично
5. Goroutines informer factories останавливаются асинхронно после `cancel()` — это нормально, k8s client-go гарантирует graceful shutdown при отмене контекста
6. После `RemoveNamespace` и до следующего `AddNamespace` — любые события от k8s для этого NS будут проигнорированы (informers остановлены, listers удалены)
+279
View File
@@ -0,0 +1,279 @@
# Forensic Architecture Audit: Fission Fork (multitenant, May 2026)
> Актуальная редакция. Легаси-версия: `FORENSIC_ARCHITECTURE_AUDIT_LEGACY_2026-05.md`
> Обновлено: 2026-05-18 после реализации namespace lifecycle hardening.
---
## Статус исправлений
| Риск | Статус | Коммит |
|------|--------|--------|
| Informer goroutine/FD leak при TrackOnly removal | ✅ ЗАКРЫТ | `4eedf95f` |
| Router stale routes при повторном добавлении NS | ✅ ЗАКРЫТ | `4eedf95f` |
| Executor dedup dirty state при re-add NS | ✅ ЗАКРЫТ | `4eedf95f` |
| Синхронный subscriber dispatch (onboarding latency) | ✅ ЗАКРЫТ | предыдущая сессия |
| `DefaultNSResolver` только append (нет RemoveNamespace) | ✅ ЗАКРЫТ | предыдущая сессия |
| Stuck-failed namespace без auto-recovery | ✅ ЗАКРЫТ | `919e8439` |
| AdoptExistingResources race при rolling update | ✅ ЗАКРЫТ | `2a7d6101` |
| No Explicit State Machine (implicit phase transitions) | ⚠️ СМЯГЧЕНО | `919e8439` |
| Sharded mutex (bottleneck при >500 concurrent tenant) | ⏳ BACKLOG | не актуально при текущей нагрузке |
---
## Architectural Decisions (реально принятые)
- **Dynamic Namespace Discovery**: Механизм динамического обнаружения и подключения tenant-namespace через label `fission.io/managed=true` (`pkg/utils/namespace_manager.go`, `pkg/executor/multitenant/ns_watcher.go`).
- **Namespace Lifecycle Management**: Жизненный цикл namespace централизован через интерфейс `NamespaceManager` с подписчиками (executor, router, buildermgr).
- **Decoupled Registration**: Каждый компонент подписывается как `NamespaceSubscriber` и реализует свою логику инициализации/чистки ресурсов.
- **Backward Compatibility**: Поддержка статического списка через env (`FISSION_RESOURCE_NAMESPACES`) с динамическим расширением.
- **No-Restart Onboarding**: Добавление tenant не требует рестарта pod-ов.
- **RBAC/SA Provisioning**: Автоматическое создание SA и RBAC для новых namespace (`EnsureNamespaceSA`).
- **Informer Factories Per Namespace**: Отдельная informer factory для каждого NS, с per-NS context cancellation.
- **Explicit Namespace Removal Strategy**: `DispatchRemove` — при удалении NS вызываются `RemoveFunc` у всех подписчиков, останавливаются informer-ы через `context.CancelFunc`.
- **Parallel Subscriber Dispatch**: Подписчики вызываются параллельно через `errgroup` — onboarding не блокируется медленным SA provisioning.
---
## Core Complexity Centers
- **NamespaceManager & Watcher**: Центр всей динамики — координация событий, фаз, подписчиков.
- **ExecutorType Subsystems**: Poolmgr, NewDeploy, Container — каждый хранит собственный per-NS кэш, lister-ы, логику adoption и reaping.
- **Informer Lifecycle**: Динамическое создание/остановка informer-ов через per-NS `context.CancelFunc`. Чистка `envLister[ns]`/`deplLister[ns]`/`triggerInformer[ns]` при `RemoveNamespace`.
- **FunctionServiceCache**: Кэширование и lifecycle function pod-ов, синхронизация с событиями из разных источников. **Не очищается при RemoveNamespace**`idleObjectReaper` убирает устаревшие записи через `IsValid()` check.
---
## Hidden Coupling & Accidental Complexity
- **Implicit Contract**: Все компоненты обязаны реализовывать `NamespaceSubscriber` симметрично (и `AddFunc`, и `RemoveFunc`). Нарушение → silent drift.
- **Global vs Local State**: Глобальный `DefaultNSResolver` + локальные lister-ы в каждом executor type. `RemoveNamespace` в NSResolver и в каждом executor type должны быть вызваны согласованно.
- **Deduplication Responsibility**: `AddNamespace` дедупликация — через `DefaultNSResolver().AddNamespace()` возвращающий `bool`, и через проверку локального lister-а (`envLister[ns] != nil`). После `RemoveNamespace` оба guard сбрасываются → re-add корректно создаёт новые informer-ы.
- **Event Handler Ordering**: Порядок подписчиков в `Subscribe` влияет на side-effects, но `errgroup` делает их параллельными — ordering больше не определяет latency, но всё ещё влияет на приоритет ошибок.
- **RBAC Drift**: Provisioning SA/RBAC в `registerNamespace`, cleanup — в `deregisterNamespace`. При сбое cleanup — dangling SA/ClusterRoleBinding.
---
## Workaround-Driven Decisions
- ~~**Track-Only Removal**~~ → **ЗАМЕНЕНО** на `DispatchRemove` — cleanup вызывается всегда.
- **Manual Adoption**: При старте executor-ы делают adopt orphaned ресурсов. Закрыто: `PreRegisterManagedNamespaces` обеспечивает полный NS snapshot до adopt/cleanup (§1.3).
- **Explicit Reaper Loops**: `idleObjectReaper` чистит `FunctionServiceCache` вместо event-driven подхода. Приемлемо: `IsValid()` check достаточен при корректной работе per-NS informer-ов.
---
## Fragile Operational Components
- **RBAC/SA Drift**: Неконсистентность между созданием и удалением SA/ролей при сбое в `deregisterNamespace`.
- **Cache Invalidation**: `FunctionServiceCache` не очищается при `RemoveNamespace` — расчёт на `idleObjectReaper`. При высоком churn rate может накапливать stale записи быстрее, чем reaper убирает.
- **Adoption Race**: ~~`AdoptExistingResources` vs `namespace_subscriber` — активная проблема~~ — закрыто: `PreRegisterManagedNamespaces` перед adopt/cleanup (`2a7d6101`).
- **Stuck Failed Phase**: ~~Namespace в `failed` не восстанавливается без рестарта~~ — закрыто: `RunReconciler` + полная цепочка error propagation (§1.4).
---
## Poor Scalability Risks
- **Informer Explosion**: ~15002000 goroutine при 100 tenant (см. §2). **Частично смягчено**: goroutine-ы корректно останавливаются при `RemoveNamespace` — нет накопления при churn. Но в steady-state 100 NS — линейный рост горутин остаётся.
- ~~**Synchronous Dispatch**~~ → **ИСПРАВЛЕНО**: параллельный dispatch через `errgroup`.
- **Centralized Locking**: Глобальный mutex на NamespaceManager. При текущей нагрузке (<50 ns) — не узкое место. При >500 concurrent tenant — backlog (sharded mutex, §5).
- **Thundering Herd на resync**: 100 NS × 5 informer-типов × LIST каждые 30 мин — 500 concurrent LIST к API.
---
## Future Maintenance Problems
- **Hidden State Machines**: Фазы namespace реализованы неявно — сложно дебажить stuck state. Нет формализованной машины состояний с explicit transitions.
- **Implicit Error Handling**: Ошибки в `deregisterNamespace` логируются, но NS может остаться в некорректном состоянии. Нет `NamespaceCondition` на k8s-объекте.
- **Contract Drift**: Изменение интерфейса `NamespaceSubscriber` (например, добавление `ResyncFunc`) требует синхронного обновления всех компонентов.
- **FunctionServiceCache без per-NS cleanup**: если `idleObjectReaper` будет отключён/изменён — stale cache может накапливаться.
---
## Risky / Hard-to-Maintain Decisions
| Решение | Статус | Примечание |
|---------|--------|------------|
| Informer Lifecycle Management | ✅ Hardened | per-NS context cancel + RemoveNamespace во всех компонентах |
| Centralized Mutex | ⚠️ Приемлемо | sharding в backlog, не актуально до >500 NS |
| Manual Adoption | ✅ Закрыто | race при rolling update (`2a7d6101`) |
| No Explicit State Machine | ✅ Частично закрыто | stuck-failed закрыт (`919e8439`); явная state machine в backlog |
| Eventual Consistency | ⚠️ Смягчено | параллельный dispatch уменьшает окно, но не устраняет |
---
## Multi-Tenancy, Isolation, Orchestration, Lifecycle, State, Reconciliation
- **Multi-Tenancy**: Label-based discovery, каждый tenant — отдельный namespace, изоляция на уровне k8s.
- **Isolation Model**: Namespace-level isolation, per-NS SA/RBAC, per-NS informer factory.
- **Lifecycle Management**: Фазы (discovered → registering → active → deregistering → removed / failed) реализованы. Auto-recovery из failed работает через `RunReconciler`. Явная state machine в backlog.
- **State Handling**: Глобальный `DefaultNSResolver` + локальные lister-ы. После `RemoveNamespace` — оба синхронизованы. После re-add — оба корректно инициализируются заново.
- **Reconciliation Logic**: Каждый компонент через subscribe. Отсутствует reconcile-очередь для failed state.
- **Operational Burden**: Средний — goroutine leak устранён, stale informer устранён, stuck-failed закрыт. Требуется мониторинг: orphaned SA/RBAC при неудачном deregister.
---
## Engineering Maturity
- **Maturity**: Архитектурно зрелый, хорошо документированный, с явным reasoning и поэтапным внедрением.
- **Complexity**: Высокая в синхронизации и lifecycle. Снижена за счёт формализации `RemoveNamespace` контракта.
- **Maintainability**: Среднесрочная — без явной state machine сложность будет расти. Auto-recovery из failed работает.
- **Production-Grade**: Близко — informer lifecycle корректен, dispatch параллелен, cleanup симметричен, stuck-failed закрыт, AdoptExistingResources race закрыт.
---
# Deep Risk Analysis (актуальная, May 2026)
---
## 1. Сценарии отказа
### 1.1 Informer Lifecycle Management — ✅ ЗАКРЫТ
**Что было:** relabel-цикл NS создавал phantom-состояние: informer-ы не останавливались при track-only removal, `DefaultNSResolver` не очищал запись → re-add возвращал `false` → новые informer-ы не создавались.
**Что сделано (коммит `4eedf95f`):**
- `RemoveNamespace(ns)` добавлен в интерфейс `ExecutorType` и реализован в poolmgr, newdeploy, container.
- В каждом executor type: per-NS context cancel (`nsCancels map[string]context.CancelFunc`). `AddNamespace` создаёт `nsCtx, nsCancel := context.WithCancel(ctx)`, передаёт `nsCtx` в `factory.Start()`. `RemoveNamespace` вызывает `nsCancel()` и удаляет lister-ы из карт.
- Router: `HTTPTriggerSet.RemoveNamespace()` отменяет per-NS ctx, удаляет `triggerInformer[ns]`/`funcInformer[ns]` под `informerMu.Lock()`, вызывает `syncTriggers()`.
- Buildermgr: `envWatcher.RemoveNamespace()` и `pkgWatcher.RemoveNamespace()` — аналогично.
- `DefaultNSResolver.RemoveNamespace(ns)` удаляет NS из глобального map → re-add корректно проходит guard.
- Стратегия `DispatchRemove` во всех 3 компонентах → `RemoveFunc` вызывается при удалении NS.
**Текущий статус:** informer goroutine/FD корректно останавливаются; re-add NS создаёт чистые informer-ы; router не видит stale routes.
---
### 1.2 Centralized Mutex — ⚠️ ПРИЕМЛЕМО
**Сценарий:** высокая churn + concurrent Snapshot.
`dispatch()` отпускает mutex перед вызовом каждого subscriber, берёт снова для следующего. При батч-онбординге 10+ NS параллельно: конкуренция за mutex, latency spike на `Snapshot()` в `idleObjectReaper`.
**Смягчено:** `dispatch()` теперь параллельный (errgroup) — подписчики не вызываются последовательно, время блокировки mutex между подписчиками устранено. `Snapshot()` конкурирует только с `Upsert` — при текущей нагрузке (<50 NS) практически нет.
**Остаётся:** при >500 concurrent tenant с >1 onboarding/sec — sharded mutex даст выигрыш. В backlog.
---
### 1.3 Manual Adoption (AdoptExistingResources) — ✅ ЗАКРЫТ (коммит `2a7d6101`)
**Сценарий: гонка adoption vs watcher при старте**
**Что было:** `AdoptExistingResources` и `CleanupOldExecutorObjects` запускались до `StartNSWatcher`. `DefaultNSResolver().Snapshot()` возвращал только статические NS из `FISSION_RESOURCE_NAMESPACES` → managed NS не покрывались:
- Pods от предыдущего executor в managed NS не adoptировались (сохраняли старый `instanceID`) → poolmgr создавал новые pool pods → cold start.
- Старые RS/deployments в managed NS не чистились → накапливались.
**Что сделано:** `multitenant.PreRegisterManagedNamespaces(ctx, logger, kubernetesClient)` — синхронный `Namespaces.List` с label `fission.io/managed=true` вызывается в `executor.go` **до** goroutines adopt+cleanup. Добавляет все managed NS в `DefaultNSResolver`. Идемпотентен с последующим `AddFunc` из watcher. Не ломает при ошибке API (warn + proceed).
**End-to-end после фикса:**
1. `PreRegisterManagedNamespaces``DefaultNSResolver` содержит static + managed NS
2. `AdoptExistingResources` → патчит pods в managed NS с новым `instanceID`
3. `CleanupOldExecutorObjects` / `GetReaperNamespace()` → видит managed NS → чистит стale объекты
4. `StartNSWatcher``AddFunc` срабатывает для тех же NS — `DefaultNSResolver().AddNamespace()` idempotent, `AddNamespace` executor types dedup-protected
---
### 1.4 No Explicit State Machine — ✅ ЗАКРЫТ (коммит `919e8439`)
**Сценарий: stuck в `failed` без auto-recovery**
**Что было:** `EnsureNamespaceSA` и `registerNamespace` были void-функциями — ошибки только логировались, до `MarkPartFailed` не доходили. Executor subscriber всегда возвращал nil → namespace никогда не попадал в `NamespacePhaseFailed``RunReconciler` для executor был мёртвым кодом.
**Что сделано:**
- `setupSAAndRoleBindings` → возвращает `error`
- `EnsureNamespaceSA` → возвращает `error`, пробрасывает
- `registerNamespace` → возвращает `error` (SA + executorTypes) с `fmt.Errorf` wrapping
- Executor `AddFunc`/`ResyncFunc` → пробрасывают ошибку вместо `return nil`
- `RunReconciler` → принимает `*zap.Logger`, логирует каждый retry и исход
**End-to-end flow:**
1. `EnsureNamespaceSA` fails (k8s 503) → `registerNamespace` returns error
2. Executor AddFunc returns error → `dispatch()``MarkPartFailed("executor")`
3. `deriveNamespacePhase``NamespacePhaseFailed`
4. `RunReconciler` tick (30s) находит namespace → `DispatchResync` → retry
5. Если API восстановился: `MarkPartActive``NamespacePhaseActive` → лог `resync succeeded`
**Накопление при churn:** ликвидировано — failed NS автоматически выходят из этой фазы при восстановлении API.
**Ограничение:** нет max-retries. Namespace, у которого SA создать принципиально невозможно (например, удалённый k8s namespace), будет ретраиться вечно. Приемлемо на текущем масштабе.
---
### 1.5 Eventual Consistency — ⚠️ СМЯГЧЕНО
**Сценарий:** HTTPTrigger создан в окне до готовности informer.
**Было:** последовательный dispatch → если executor делал SA provisioning 1030 сек, router не начинал `WaitForCacheSync`. Trigger, созданный в этом окне, пропускался до следующего resync (30 мин).
**Смягчено:** параллельный dispatch через errgroup → router и executor стартуют `AddNamespace` одновременно. Окно уязвимости = время `WaitForCacheSync` в router (~2–5 сек), а не время SA provisioning (~30 сек).
**Остаётся:** trigger, созданный за 2–5 сек до `WaitForCacheSync` в router → нормально обрабатывается через `AddFunc` после sync. Фактически проблема устранена для практических сценариев.
---
## 2. Анализ при 50100 tenant с churn 10 ns/час
### Informer Count (steady-state)
При 100 активных tenant:
- **Poolmgr**: 2 factory × 100 NS × ~35 goroutine = **6001000 goroutine**
- **NewDeploy**: аналогично ~6001000 goroutine
- **Router**: 1 factory × 100 NS × ~2 goroutine = **200 goroutine**
- **Buildermgr**: ~200 goroutine
Итого: **~16002400 goroutine** от informer-ов. **Линейный рост с числом NS — неизбежен при текущей архитектуре.**
**Что изменилось после hardening:** при churn goroutine-ы корректно останавливаются при `RemoveNamespace` — нет накопления мёртвых goroutine. Steady-state = ~O(active_NS), а не O(total_NS_ever_seen).
### Thundering Herd на resync
100 NS × 5 informer-типов × LIST каждые 30 мин = **500 concurrent LIST** к Kubernetes API. Не изменилось, не исправлено.
### Stuck Failed Accumulation — ✅ ЗАКРЫТ
Failed NS автоматически ретраятся `RunReconciler` каждые 30с и выходят из `failed` при восстановлении API. Накопления больше не происходит.
### AdoptExistingResources Race — ✅ ЗАКРЫТ
`PreRegisterManagedNamespaces` синхронно добавляет managed NS в `DefaultNSResolver` до adopt/cleanup. Старые pods adoptируются, stale объекты чистятся. Подробно — §1.3.
---
## 3. Рекомендации (приоритизированные)
### P1 — Reconcile-очередь для failed NS — ✅ ЗАКРЫТ (`919e8439`)
Error propagation исправлена во всей цепочке: `setupSAAndRoleBindings``EnsureNamespaceSA``registerNamespace` → executor subscriber. `RunReconciler` логирует retry и исход.
### P2 — AdoptExistingResources после BootstrapAndDispatch — ✅ ЗАКРЫТ (`2a7d6101`)
`PreRegisterManagedNamespaces` вызывается синхронно до adopt/cleanup. Делает один `Namespaces.List(label=fission.io/managed=true)` → добавляет все managed NS в `DefaultNSResolver`. После этого adopt и cleanup покрывают полный tenant NS set.
**Влияние:** устранены orphaned pods при холодном старте и resource leak (stale RS/deployments).
### P3 — NamespaceCondition на k8s Namespace объекте
Пометить Namespace через `kubectl annotate` или через status subresource при failed phase → оператор видит причину без чтения логов.
### Backlog — Sharded mutex
Актуально при >500 concurrent tenant с >1 onboarding/sec. Технически feasible без breaking interface change (см. `FORENSIC_ARCHITECTURE_AUDIT_LEGACY_2026-05.md §5`).
---
## 4. FunctionServiceCache — текущий инвариант
`FunctionServiceCache` (`fsCache` в gpm и newdeploy) **не очищается** при `RemoveNamespace`. Это осознанное решение:
- `idleObjectReaper` периодически вызывает `fsCache.ListOldForPool()` → для каждой записи проверяет `podLister[ns]` → если NS удалён, `podLister[ns]` == nil → pod не найден → запись считается expired → `fsCache.DeleteEntry()`.
- Временной лаг = интервал reaper-а (по умолчанию ~1 мин). При высоком churn возможно накопление stale записей, но они не вызывают функциональных ошибок (только небольшой overhead на reaper iteration).
**Когда станет проблемой:** при отключении/изменении reaper-а или при >10 000 stale записей (O(n) iteration).
---
## 5. Sharded Mutex — вердикт
**Технически реализуемо** без breaking interface change. Полный код — в `FORENSIC_ARCHITECTURE_AUDIT_LEGACY_2026-05.md §5`.
**Вердикт:** не оправдано при текущей нагрузке. Реальный bottleneck — AdoptExistingResources race — закрыт (`2a7d6101`). Sharded mutex — в backlog, актуально при >500 concurrent tenant с >1 onboarding/sec.
@@ -0,0 +1,385 @@
# Forensic Architecture Audit: Fission Fork (feature/multitenant, May 2026)
---
## Architectural Decisions (реально принятые)
- **Dynamic Namespace Discovery**: Введён механизм динамического обнаружения и подключения tenant-namespace через label `fission.io/managed=true` (см. `pkg/utils/namespace_manager.go`, `pkg/executor/multitenant/ns_watcher.go`).
- **Namespace Lifecycle Management**: Весь жизненный цикл namespace теперь централизован через интерфейс `NamespaceManager` с подписчиками (executor, router, buildermgr).
- **Decoupled Registration**: Каждый компонент (executor, router, buildermgr) подписывается как subscriber и реализует свою логику инициализации/чистки ресурсов при появлении/удалении namespace.
- **Backward Compatibility**: Сохраняется поддержка статического списка через env (`FISSION_RESOURCE_NAMESPACES`), но теперь он расширяется динамически.
- **No-Restart Onboarding**: Добавление нового tenant не требует рестарта pod-ов — watcher реагирует на label, триггерит регистрацию во всех подсистемах.
- **RBAC/SA Provisioning**: Автоматическое создание service account и RBAC для новых namespace (см. `EnsureNamespaceSA`).
- **Informer Factories Per Namespace**: Для каждого нового namespace создаются отдельные informer factory для CRD и core-ресурсов.
- **Explicit Namespace Removal Strategy**: Поддержка двух стратегий удаления: track-only (по умолчанию) и dispatch-remove (с вызовом OnNamespaceRemove у подписчиков).
## Core Complexity Centers
- **NamespaceManager & Watcher**: Центр всей динамики — сложная координация событий, фаз, подписчиков, race-conditions.
- **ExecutorType Subsystems**: Poolmgr, NewDeploy, Container — каждый хранит собственное состояние, кэш, логику adoption и reaping.
- **Informer Lifecycle**: Динамическое создание/удаление informer-ов на лету для каждого namespace.
- **FunctionServiceCache**: Кэширование и lifecycle function pod-ов, синхронизация с событиями из разных источников.
## Hidden Coupling & Accidental Complexity
- **Implicit Contract**: Все компоненты обязаны корректно реализовать NamespaceSubscriber — нарушение приводит к silent drift.
- **Global vs Local State**: Есть глобальный NamespaceResolver и локальные состояния в каждом executor type — возможны рассинхронизации.
- **Deduplication Responsibility**: Deduplication namespace размазан между глобальным резолвером и локальными структурами.
- **Event Handler Ordering**: Порядок подписчиков влияет на фазу и side-effects, но не гарантируется явно.
- **RBAC Drift**: Provisioning SA/RBAC делается в одном месте, но cleanup — в другом, возможны dangling ресурсы.
## Iterative Growth
- **Layered Refactor**: Ветка развивается через серию малых шагов (см. doc/thinking/2026-04-26-namespace-manager-step*.md), каждый шаг — отдельный инвариант.
- **Hybrid Model**: Некоторое время coexist старый статический и новый динамический pipeline, с явным разделением путей.
- **Feature Flags via Env**: Многое управляется через env-переменные, что позволяет поэтапно включать/выключать новые механики.
## Workaround-Driven Decisions
- **Track-Only Removal**: По умолчанию удаление namespace не вызывает cleanup в подписчиках — workaround против race-condition при массовых удалениях.
- **Manual Adoption**: При старте executor-ы делают adopt orphaned ресурсов (pods, deployments) — workaround для несовершенного lifecycle.
- **Explicit Reaper Loops**: Для чистки orphaned объектов используются отдельные циклы (object reaper), а не event-driven подход.
## Fragile Operational Components
- **Informer Factory Lifecycle**: Ошибки в динамическом создании/удалении informer-ов приводят к memory leak или stale watchers.
- **RBAC/SA Drift**: Неконсистентность между созданием и удалением сервисных аккаунтов и ролей.
- **Cache Invalidation**: FunctionServiceCache может рассинхронизироваться при сбоях в event flow.
- **Adoption Loops**: AdoptExistingResources может не покрыть все edge-case, особенно при race между startup и watcher.
## Poor Scalability Risks
- **Informer Explosion**: На сотнях/тысячах namespace число informer-ов и goroutine растёт линейно, возможен memory/FD exhaustion.
- **Synchronous Dispatch**: Все подписчики вызываются синхронно, при долгой инициализации одного — блокируются остальные.
- **Centralized Locking**: NamespaceManager держит глобальный mutex на все операции — bottleneck при высокой churn rate.
- **No Sharding**: Нет горизонтального масштабирования NamespaceManager — всё в одном процессе.
## Future Maintenance Problems
- **Hidden State Machines**: Фазы namespace и частей (part state) реализованы неявно, без явной state machine — сложно дебажить stuck state.
- **Implicit Error Handling**: Ошибки в подписчиках часто логируются, но не эскалируются — возможна silent failure.
- **Contract Drift**: Любое изменение интерфейса NamespaceSubscriber требует синхронного обновления всех компонентов.
- **Complex Test Surface**: Много интеграционных точек, сложно покрыть тестами все сценарии гонок и отказов.
## Deepest Upstream Divergence
- **Полная замена статической модели discovery на динамическую через watcher и NamespaceManager.**
- **Весь lifecycle tenant-namespace теперь event-driven, а не env-driven.**
- **Введён централизованный интерфейс подписки на события namespace для всех core-компонентов.**
- **Механика adopt orphaned ресурсов и явная поддержка rollback/cleanup.**
## Surprisingly Mature Parts
- **Интерфейс NamespaceManager**: Чётко выделен, покрыт тестами, поддерживает snapshot, summary, phase tracking.
- **Event Handler Abstraction**: Все watcher-ы используют единый event handler contract, легко расширять.
- **Backward Compatibility Layer**: Старый pipeline не сломан, coexist с новым.
- **Документация и коммиты**: Подробные шаги, объяснения, reasoning — видно зрелый инженерный подход.
## Risky / Hard-to-Maintain Decisions
- **Informer Lifecycle Management**: Очень сложно гарантировать отсутствие leak/stale при динамике.
- **Centralized Mutex**: Один mutex на NamespaceManager — риск блокировок.
- **Manual Adoption**: AdoptExistingResources — временное решение, не покрывает все сценарии.
- **No Explicit State Machine**: Фазы и переходы не формализованы, возможны stuck state.
- **Eventual Consistency**: Нет гарантии моментальной консистентности между компонентами.
---
## Multi-Tenancy, Isolation, Orchestration, Lifecycle, State, Reconciliation
- **Multi-Tenancy**: Реализовано через label-based discovery, каждый tenant — отдельный namespace, все ресурсы изолированы на уровне k8s.
- **Isolation Model**: Namespace-level isolation, автоматическое создание SA/RBAC, informer-ы и кэш на каждый tenant.
- **Orchestration**: NamespaceManager + подписчики — централизованный event bus для всех core-компонентов.
- **Lifecycle Management**: Поддержка всех фаз (discovered, registering, active, deregistering, removed, failed), но state machine неявная.
- **State Handling**: Гибрид глобального и локального состояния, возможны рассинхронизации.
- **Reconciliation Logic**: Каждый компонент реализует свою reconcile-логику через подписку на события.
- **Controller Complexity**: Высокая, много слоёв абстракции, много точек гонок.
- **Deployment Reproducibility**: Helm-чарты поддерживают все новые env, backward compatibility сохранён.
- **Operational Burden**: Высокий — требуется мониторинг leak, race, orphaned ресурсов, ручной контроль за adoption.
---
## Engineering Maturity, Complexity, Maintainability Horizon
- **Maturity**: Архитектурно зрелый, хорошо документированный, с явным reasoning и поэтапным внедрением.
- **Complexity**: Высокая, особенно в динамике и синхронизации между компонентами.
- **Maintainability**: Среднесрочная — без явной state machine и горизонтального масштабирования возможны проблемы при росте нагрузки.
- **Production-Grade**: Ближе к production-grade platform engineering, чем к эксперименту, но требует доработки по масштабированию и явной формализации state transitions.
---
## Architectural Drift / Entropy / Hazards
- **Drift**: Возможен drift между глобальным и локальным состоянием, если подписчики реализованы несимметрично.
- **Entropy**: Много точек входа, implicit contract, нет явной state machine — сложность будет расти.
- **Hazards**: Memory leak, race-condition, orphaned ресурсы, silent failure при ошибках в подписчиках.
---
## Summary
Этот форк — зрелая попытка перевести Fission на event-driven multi-tenant архитектуру с динамическим discovery и централизованным lifecycle management. Основные сложности и риски — в управлении состоянием, синхронизации и масштабируемости. Требует дальнейшей формализации state machine, горизонтального масштабирования и усиления тестового покрытия для production-grade эксплуатации.
---
# Deep Risk Analysis (May 2026)
> Конкретные сценарии отказа, оценка при 50–100 tenant, предложения по исправлению.
---
## 1. Сценарии отказа для каждого "Risky Decision"
### 1.1 Informer Lifecycle Management
**Сценарий: повторная регистрация namespace через relabel**
1. Оператор снимает label `fission.io/managed=true` с namespace `tenant-42`.
2. Namespace-watcher вызывает `HandleWatcherNamespaceRemoval()`. Стратегия `TrackOnly`: NamespaceManager помечает запись как `removed` и **не вызывает** `OnNamespaceRemove` у подписчиков.
3. Informer-ы executor (gpm, newdeploy) и router продолжают работать — pool для tenant-42 жив, функции маршрутизируются.
4. Оператор возвращает label — kubernetes генерирует `MODIFIED`-событие.
5. `RunManagedNamespaceWatcher` (resync 30 мин) может не вызвать Add снова для уже известного NS.
6. **Router**: `AddNamespace` вызывает `DefaultNSResolver().AddNamespace(ns)`. Глобальный resolver уже содержит tenant-42 (его никто не удалял из-за track-only) → возвращает `false` → router делает **early return без создания новых informer-ов** (строка 460 `httpTriggers.go`). Router считает namespace активным (старые informer-ы ещё работают) — но если они были остановлены контекстом — тихое 404.
7. **Executor**: `gpm.AddNamespace` проверяет `poolPodC.envLister[ns]` — если старый lister жив, возвращает nil сразу (дедупликация). Всё выглядит нормально, но фактически используются **устаревшие informer-ы** с застрявшим кэшем.
**Итог**: relabel-цикл создаёт phantom-состояние: компоненты думают что NS активен, но его lifecycle разорван.
---
### 1.2 Centralized Mutex
**Сценарий: высокая churn + concurrent Snapshot**
`dispatch()` снимает write-lock перед вызовом каждого subscriber-а, затем берёт его снова для следующего. Структура:
```
mu.Lock() → читаем список subs →
mu.Unlock() → вызываем handler(sub1) [k8s API call, может занять сотни мс]
mu.Lock() → читаем следующий sub →
mu.Unlock() → вызываем handler(sub2)
```
Параллельно: router каждые 20 мс делает `syncTriggers()``updateRouter()` → итерирует `snapshotFuncInformers()` → берёт `informerMu.RLock`. Это другой mutex, но `DefaultNSResolver().Snapshot()` вызывается из `idleObjectReaper` каждые 5 сек под глобальным `RWMutex` NamespaceManager.
При 100 tenant с churn 10 ns/час: в среднем каждые 6 мин добавляется namespace. Само по себе безвредно. Но при пике (батч-онбординг 10 tenant за 1 минуту): `dispatch()` держит write-lock с паузами на unlock/relock для каждого subscriber × 10 параллельных dispatch → конкуренция за mutex возрастает. `Snapshot()` в `idleObjectReaper` (каждые 5 сек) и в `AdoptExistingResources` (каждый рестарт) будут ждать.
**Итог**: не deadlock, но latency spike на Snapshot на старте и при батч-онбординге — 200–500 мс при 10+ concurrent dispatch.
---
### 1.3 Manual Adoption (AdoptExistingResources)
**Сценарий: гонка adoption vs watcher**
1. Executor стартует. `AdoptExistingResources` запускается, берёт `DefaultNSResolver().Snapshot()` — snapshot содержит только статические NS из `FISSION_RESOURCE_NAMESPACES`.
2. Параллельно запускается `RunManagedNamespaceWatcher`. Watcher вызывает `BootstrapAndDispatch()`, который регистрирует managed NS и вызывает `registerNamespace()` у executor-подписчика.
3. `registerNamespace()` вызывает `DefaultNSResolver().AddNamespace(ns)` (глобальный guard), затем `gpm.AddNamespace()`.
4. **Но `AdoptExistingResources` уже завершила свой loop** — managed NS не попал в snapshot. Orphaned pods в tenant NS не приняты.
5. Функции в этих pod-ах будут вызываться ещё раз через cold start — лишний latency spike и потеря статуса `instanceID` у подов (старый instanceID в annotation не перезаписан → `CleanupOldExecutorObjects` сочтёт их orphaned → удалит).
**Hardcoded 30s timeout**: `AdoptExistingResources` в poolmgr не имеет явного timeout, но `k8sCache.WaitForCacheSync` в `Run()` блокирует до готовности — только после этого запускается `service()`. Если namespace watcher опередил, poolmgr получит env-события до того как `AdoptExistingResources` завершится → гонка на `gpm.pools` map (не защищена mutex вне `service()` goroutine).
---
### 1.4 No Explicit State Machine
**Сценарий: stuck в `failed` без auto-recovery**
1. Namespace `tenant-99` помечен `fission.io/managed=true`.
2. `registerNamespace()` вызывает `EnsureNamespaceSA()` — Kubernetes API momentarily unavailable (503).
3. `EnsureNamespaceSA()` возвращает ошибку → вызывающий код (предположительно) пишет в лог и помечает часть как `NamespacePartStateFailed`.
4. `deriveNamespacePhase()` выставляет namespace в `NamespacePhaseFailed`.
5. **Нет reconcile-цикла**: нет горутины, которая периодически проверяет failed namespace и пытается повторить. Phase останется `failed` до рестарта процесса.
6. Router был вызван следующим в цепочке dispatch. Т.к. dispatch вызывается подписчики последовательно без barrier, router **уже создал свои informer-ы** до того как executor завершился с ошибкой.
7. **Dirty state**: router видит `tenant-99` как активный (informer-ы есть), executor — нет (SA/RBAC не создан). Любой вызов функции из tenant-99 → executor не может специализировать pod (нет fetcher SA) → 503.
Лог покажет ошибку, но namespace останется в `failed` навсегда (до рестарта). Оператор не получит никакого k8s-статуса — ни condition на Namespace объекте, ни event.
---
### 1.5 Eventual Consistency
**Сценарий: HTTPTrigger создан в окне до ready informer**
1. Tenant создаёт namespace с label → namespace добавляется в NamespaceManager.
2. `dispatch()` вызывает router subscriber → `AddNamespace()`:
```go
k8sCache.WaitForCacheSync(ctx.Done(), triggerInf.HasSynced, funcInf.HasSynced)
ts.syncTriggers()
```
Router ждёт sync и перестраивает роутинг. Это занимает несколько секунд.
3. Tenant **немедленно** после создания namespace создаёт HTTPTrigger через API.
4. Если trigger создан **до** завершения `WaitForCacheSync` в router → informer ещё не синхронизирован, но trigger уже в etcd.
5. После sync informer получит это событие через `AddFunc` → `syncTriggers()`. Это нормально.
6. **Проблема в другом**: `dispatch()` вызывает подписчиков **последовательно**. Если executor (первый в списке) занимается `EnsureNamespaceSA` + `registerExecutorTypes` (10–30 сек при медленном API) → router subscriber не вызывается всё это время. HTTPTrigger, созданный в этом окне, попадёт в informer, но router ещё не начал слушать → `AddFunc` для этого trigger не вызовется никогда (resync через 30 мин).
7. Результат: trigger существует в etcd, но **отсутствует в роутере 30 минут**.
---
## 2. Анализ при 50100 tenant с churn 10 ns/час
### Informer Explosion
При 100 активных tenant:
- **Executor (poolmgr)**: 1 `SharedInformerFactory` (Fission CRD) + 1 `SharedInformerFactory` (k8s pods/RS) на NS = 200 factory. Каждая factory запускает горутины на каждый informer (~35 горутин). **~6001000 goroutine** только от poolmgr.
- **Executor (newdeploy)**: аналогично — ещё 200 factory, ~600 goroutин.
- **Router**: 1 factory на NS = 100 factory, ~200 goroutин.
- **buildermgr**: 1 factory на NS = 100 goroutин.
Итого: **~15002000 goroutine** только от informer-ов. При пике churn (10 ns/час) — каждые 6 минут добавляется NS, создаётся ~20 новых горутин, они не убираются при track-only removal.
При **100 NS × 30 мин resync**: каждые 30 мин каждый informer делает LIST всех объектов в своём NS. 100 × 5 informer-типов × LIST = **500 concurrent LIST-запросов** к Kubernetes API раз в 30 минут — возможный thundering herd.
### Stuck Failed State
10 ns/час churn с 1% API error rate = ~2.4 failed namespace/сутки. Каждый остаётся в `failed` навсегда. За 30 дней = ~72 "мёртвых" записи в NamespaceManager. `Snapshot()` возвращает их в `idleObjectReaper` → лишние LIST к k8s API для несуществующих/неактивных NS → ошибки, логи, load.
### AdoptExistingResources Race
Каждый рестарт executor-а — race. При rolling update в k8s (новый pod стартует, старый ещё жив): оба executor-а параллельно делают `AdoptExistingResources` → оба патчат `instanceID` на одних и тех же pod-ах → `CleanupOldExecutorObjects` нового экземпляра удаляет pod-ы старого (ожидаемо), но при race может удалить pod, который новый экземпляр уже adoptировал.
### Router Dedup Gap — критический сценарий при рестарте
При рестарте executor + router одновременно:
1. `FISSION_RESOURCE_NAMESPACES` содержит `fission-fn` (статический NS).
2. `namespace.go` `init()` добавляет его в `DefaultNSResolver`.
3. `BootstrapAndDispatch()` в NamespaceManager вызывает dispatch для всех managed NS, включая `fission-fn`.
4. **Router** `AddNamespace("fission-fn")` → `DefaultNSResolver().AddNamespace("fission-fn")` → **false** (уже добавлен в `init()`!) → **early return, informer для fission-fn НЕ создан**.
5. Executor (gpm, newdeploy) — используют own dedup (envLister/deplLister), `fission-fn` там нет → создают informer.
6. Router слеп к HTTPTrigger и Function событиям из `fission-fn` при динамическом пути. Спасает только то, что `GetInformersForNamespaces` вызывается в `MakeHTTPTriggerSet` при старте — но только для NS из env.
**Вывод**: если `fission-fn` включён в `FISSION_RESOURCE_NAMESPACES` И помечен `fission.io/managed=true` — возможна ситуация, когда после рестарта router использует startup-informer, а executor использует watcher-informer с другим lifecycle → рассинхронизация при следующем relabel-цикле.
---
## 3. Минимальное изменение: явная state machine без полного рефакторинга
Текущая проблема: `failed` namespace остаётся в `failed` навсегда — нет retry.
**Изменение**: добавить reconcile-очередь в `inMemoryNamespaceManager` без изменения публичного интерфейса.
```go
// В inMemoryNamespaceManager добавить:
type reconcileRequest struct {
ns string
attempt int
}
reconcileQueue chan reconcileRequest // небуферизованный или с буфером 64
// В MarkPartFailed (или в dispatch при возврате ошибки от subscriber):
func (m *inMemoryNamespaceManager) enqueueReconcile(ns string, attempt int) {
select {
case m.reconcileQueue <- reconcileRequest{ns: ns, attempt: attempt}:
default: // уже в очереди, skip
}
}
// Новая горутина, запускается в BootstrapAndDispatch или отдельным методом:
func (m *inMemoryNamespaceManager) RunReconciler(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case req := <-m.reconcileQueue:
if req.attempt >= 5 { // max retries
m.logger.Error("namespace reconcile exhausted", zap.String("ns", req.ns))
continue
}
backoff := time.Duration(1<<req.attempt) * time.Second // 1, 2, 4, 8, 16 сек
time.AfterFunc(backoff, func() {
// Повторить dispatch только для failed-частей:
m.mu.RLock()
rec, ok := m.records[req.ns]
m.mu.RUnlock()
if !ok || rec.Phase != NamespacePhaseFailed {
return // уже исправлено или удалено
}
// Вызвать только тех подписчиков, у кого часть в FailedState:
m.dispatchRetry(ctx, req.ns, req.attempt+1)
})
}
}
}
```
**Изменения интерфейса**: `NamespaceManager` получает метод `RunReconciler(ctx)` — добавляется в интерфейс, но не breaking change для существующих вызывающих (можно добавить как опциональный метод или вызвать из `BootstrapAndDispatch`).
**Что НЕ меняется**: `NamespaceSubscriber`, `NamespaceRecord`, публичные методы `Upsert`/`Snapshot`/`Subscribe` — всё прежнее.
---
## 4. Track-Only Removal: скрытые допущения и dirty state
### Допущение 1: `DefaultNSResolver` — только append
`pkg/utils/namespace.go`: метод `AddNamespace` добавляет NS в глобальный map, метода `RemoveNamespace` не существует. Последствия:
- Namespace, удалённый через label-снятие, **навсегда остаётся** в глобальном resolver-е.
- `idleObjectReaper` в poolmgr и newdeploy делает `DefaultNSResolver().Snapshot()` → итерирует удалённые NS → делает LIST Environments/Functions в уже несуществующем (или чужом) namespace → получает k8s 403/404 → логирует ошибку → возвращает из reaper-а (!) — `return` на ошибке прерывает весь цикл reaper-а для текущей итерации.
### Допущение 2: Informer-ы продолжают работать
После track-only removal informer-ы executor-а и router-а **не останавливаются**. Для poolmgr: env-events из удалённого namespace продолжают триггерить создание пулов. Пулы создаются в k8s (или пытаются) — для namespace, который более не является managed. RBAC мог быть уже удалён оператором → pod-ы не могут pull fetcher image → CrashLoopBackOff в "удалённом" namespace.
### Допущение 3: FunctionServiceCache не очищается
`fsCache` (в gpm и newdeploy) содержит записи с `Function.Namespace = "tenant-42"`. После track-only removal записи не удаляются. `idleObjectReaper` находит их через `fsCache.ListOldForPool()` → пытается найти pod в `gpm.podLister["tenant-42"]` → lister ещё жив (informer работает) → pod может быть найден → считается "valid" → не reaped → запись в кэше живёт вечно.
### Допущение 4 (критическое): повторное добавление того же NS → router слеп
Последовательность:
1. NS `tenant-42` добавлен → `DefaultNSResolver().AddNamespace("tenant-42")` → **true** → router создаёт informer.
2. NS удалён (track-only) → resolver не очищен → informer router-а продолжает работать.
3. NS добавлен снова (новый tenant с тем же именем, например после namespace-переименования).
4. `AddNamespace("tenant-42")` на router-е → `DefaultNSResolver().AddNamespace("tenant-42")` → **false** (уже в map!) → **early return**.
5. Router **не создаёт новый informer** — считает что уже обслуживает namespace. Но старый informer работает с **кэшем от предыдущего tenants** — старые Function и HTTPTrigger объекты (с другими UID) видны в `funcInformer.GetStore()`.
6. Executor (gpm): `poolPodC.envLister["tenant-42"]` тоже существует → own dedup → early return → executor тоже не создаёт новый informer.
7. Новые HTTPTrigger-ы нового tenant-42 **никогда не попадут в router** (resync через 30 мин принесёт их, но с кэшем старого tenanta!).
**Результат**: dirty state — оба компонента убеждены что всё нормально, но фактически обслуживают кэш несуществующего tenant с объектами с устаревшими UID. Вызовы функций нового tenant → 404 или выполнение **функций старого tenant** если имена совпадают.
---
## 5. Оценка замены centralized mutex на sharded lock
### Техническая реализация (feasible)
```go
const numShards = 16
type shardedNamespaceManager struct {
shards [numShards]nsShard
subsMu sync.RWMutex
subs map[string]NamespaceSubscriber
// ... остальные поля
}
type nsShard struct {
mu sync.RWMutex
records map[string]NamespaceRecord // только NS принадлежащие этому шарду
}
func shardIndex(ns string) int {
h := fnv.New32a()
h.Write([]byte(ns))
return int(h.Sum32()) % numShards
}
```
`Upsert(ns, ...)` → берёт lock только шарда `shardIndex(ns)`.
`Get(ns)` → RLock только нужного шарда.
`Snapshot()` → **последовательно** берёт RLock каждого шарда, копирует, освобождает, переходит к следующему. N=16 последовательных lock-acquisitions.
### Сохранение интерфейса
Публичный интерфейс `NamespaceManager` (Upsert, Get, Snapshot, Subscribe, Dispatch) не меняется. Подписчики (`NamespaceSubscriber`) не меняются.
### Анализ выгоды
При 10 ns/час churn: **одно upsert каждые 6 минут**. Текущий bottleneck — не mutex, а:
1. Synchronous subscriber dispatch (каждый делает k8s API calls)
2. Informer resync thundering herd
3. AdoptExistingResources race
Sharded lock убирает конкуренцию за mutex при **параллельных per-namespace операциях**. Но `dispatch()` сам снимает/берёт lock несколько раз — sharding не помогает здесь (dispatch по одному NS всегда один шард).
`Snapshot()` становится чуть медленнее (16 lock-acquisitions вместо 1 RLock) при маленьком числе NS, и сопоставима при большом.
### Вердикт
**Технически реализуемо с сохранением интерфейса. Не оправдано при текущей нагрузке.**
Sharded mutex даст реальный выигрыш только если `Upsert` и `Get` вызываются **параллельно для разных NS** с частотой > 100 ops/sec. При 10 ns/час это недостижимо. Реальные bottleneck-и — в subscriber dispatch и informer lifecycle, не в mutex.
Приоритет вместо sharding:
1. Сделать subscriber dispatch **параллельным** (goroutine per subscriber с errgroup) — немедленное ускорение онбординга.
2. Добавить `RemoveNamespace` в `DefaultNSResolver` — закрывает класс dirty-state багов.
3. Добавить reconcile-очередь (см. п. 3) — закрывает stuck-failed.
Sharded lock — в backlog, актуально при > 500 concurrent tenant с > 1 onboarding/sec.
+62
View File
@@ -0,0 +1,62 @@
# Интеграционный тест: P1/P2 (namespace lifecycle hardening)
## Контекст
Ветка: `fix/namespace-lifecycle-hardening` (fission-src)
Коммиты: P1 (auto-recovery failed NS), P2 (adopt orphaned pods)
Образ executor: `naeel/fission-bundle:v1.22.1` (задеплоен 2026-05-18)
Тестирование — через **fission-console** UI.
---
## 1. Проверка P1: auto-recovery failed NS
1. Открыть fission-console, создать tenant (новый managed namespace)
2. Нарочно сломать ServiceAccount или RoleBinding:
```bash
kubectl delete sa fission-fetcher -n <tenant-ns>
```
3. В UI убедиться, что namespace ушёл в фазу `failed`
4. Через ~30 сек namespace должен автоматически восстановиться (`active`)
- SA/RB пересозданы
- Функции снова работают (запустить любую тестовую функцию)
---
## 2. Проверка P2: adopt orphaned pods после рестарта executor
1. Через fission-console вызвать несколько функций (чтобы были warm pods)
2. Рестартовать executor:
```bash
kubectl rollout restart deployment/executor -n fission
kubectl rollout status deployment/executor -n fission
```
3. Убедиться что:
- Функции продолжают работать без cold start задержки
- В логах executor есть строки `PreRegisterManagedNamespaces`, `adopt`, `cleanup`:
```bash
kubectl logs -n fission -l svc=executor --tail=100 | grep -E "PreRegister|adopt|cleanup"
```
---
## 3. Проверить отсутствие регрессий (через fission-console)
- Создание/удаление tenant работает
- Функции создаются, редактируются, выполняются
- Логи функций доступны в UI
- Нет ошибок в UI и в логах executor/router
---
## 4. Команды для диагностики
```bash
# Текущий образ executor
kubectl get deployment executor -n fission -o jsonpath="{.spec.template.spec.containers[0].image}"
# Логи executor (последние 200 строк)
kubectl logs -n fission -l svc=executor --tail=200
# Статус managed NS
kubectl get ns -l managed-by=fission
```
+644
View File
@@ -0,0 +1,644 @@
# Fission Console API — Руководство пользователя
> Версия: актуальна для модернизированного Fission с мультитенантностью (ngcloud).
---
## Базовый URL
```
https://fission.kube5s.ru/console/api
```
---
## Аутентификация
### Где взять токен
Сервер поддерживает два типа токенов — определяет автоматически по форме:
| Форма токена | Тип | Описание |
|---|---|---|
| JWT (три части через `.`) | **Production** | JWT из личного кабинета NUBES (Профиль → Токены). Валидируется через Deck API облака |
| Любая строка ≥ 6 символов | **Demo** | Любой произвольный логин — без внешней проверки. Удобно для разработки и тестирования |
| Строка < 6 символов | — | 401 |
**Production (NUBES):** JWT-токен берётся в личном кабинете NUBES → Профиль → Токены.
**Demo:** любая строка ≥ 6 символов — например `myuser@example.com` или `dev-user-1`.
### Передача токена
Два способа — оба равнозначны:
```http
X-Auth-Token: <токен>
```
```http
Authorization: Bearer <токен>
```
### POST /auth
Проверка токена и получение информации о своём namespace.
```bash
curl -X POST https://fission.kube5s.ru/console/api/auth \
-H "Content-Type: application/json" \
-d '{"token": "myuser@example.com", "env": "test"}'
```
**Параметры:**
| Поле | Описание |
|---|---|
| `token` | Токен (JWT или demo-строка) |
| `env` | Стенд: `prod`, `dev`, `test` (только для JWT; по умолчанию `test`) |
**Ответ 200:**
```json
{
"ok": true,
"env": "test",
"namespace": "fission-a3f9c1b2d4e6f8a1",
"email": "user@example.com"
}
```
**Ошибки:**
| Код | Причина |
|-----|---------|
| 400 | Тело не JSON или `token` пустой |
| 401 | Токен < 6 символов или JWT не прошёл валидацию в Deck API |
| 405 | GET вместо POST |
> **Namespace детерминирован**: `fission-` + hex(SHA256(sub)[:8]) — одинаковый токен → всегда один namespace.
> Namespace и RBAC создаются автоматически при первом обращении.
---
## Мультитенантность ★ КЛЮЧЕВОЕ ОТЛИЧИЕ
- Каждый пользователь работает в **изолированном K8s namespace**: `fission-<hash(token)>`
- Все операции (создание, список, вызов, удаление) **автоматически ограничены своим namespace**
- Указать namespace вручную **невозможно**
- Функции другого пользователя **не видны и не доступны** — любая операция над чужим объектом возвращает **404** (не 403, чтобы не раскрывать факт существования)
- **Routes изолированы**: функции разных пользователей с одинаковым именем получают разные HTTP-маршруты
### Квоты (применяются автоматически, значения по умолчанию)
| Ресурс | Лимит |
|--------|-------|
| Функции (`count/functions.fission.io`) | 20 |
| Пакеты (`count/packages.fission.io`) | 40 |
| HTTP Triggers (`count/httptriggers.fission.io`) | 20 |
| Pods | 30 |
| CPU requests (суммарно) | 1 |
| CPU limits (суммарно) | 12 |
| RAM requests (суммарно) | 2 Gi |
| RAM limits (суммарно) | 6 Gi |
> Значения настраиваются env vars (`QUOTA_REQ_CPU`, `QUOTA_PODS`, и т.д.) без пересборки.
---
## Функции
### POST /functions — Создать функцию из кода (JSON)
```bash
curl -X POST https://fission.kube5s.ru/console/api/functions \
-H "X-Auth-Token: user@domain.com" \
-H "Content-Type: application/json" \
-d '{
"name": "my-fn",
"language": "nodejs",
"code": "module.exports = async function(ctx) { return { status: 200, body: \"hello\" }; }"
}'
```
**Параметры запроса:**
| Поле | Тип | Обязательно | Описание |
|------|-----|:-----------:|---------|
| `name` | string | ✓ | Имя функции (см. правила ниже) |
| `language` | string | ✓ | Среда выполнения: `nodejs`, `python`, `go`, `php`, `ruby` |
| `code` | string | ✓ | Исходный код (строка). Максимум 1 MB |
| `entrypoint` | string | — | Точка входа (по умолчанию — зависит от языка) |
| `route` | string | — | HTTP-маршрут (по умолчанию `/<ns-suffix>/<name>`) |
| `methods` | []string | — | HTTP-методы (по умолчанию `["GET"]`) |
| `timeout` | int64 | — | Таймаут функции в секундах |
| `ttl` | string | — | Время жизни функции: `15m`, `1h`, `2d` и т.д. ★ |
**Правила именования (`name`):**
- Только строчные буквы, цифры, дефис
- Не начинается и не заканчивается дефисом
- Максимум **57 символов**
**Ответ 201:**
```json
{
"name": "my-fn",
"package": "my-fn-pkg",
"httptrigger": "my-fn-route",
"route": "/a3f9c1b2d4e6/my-fn",
"expires_at": null
}
```
> `expires_at` — время удаления функции (RFC3339), `null` если TTL не задан.
**Ошибки:**
| Код | Причина |
|-----|---------|
| 400 | Нет `name`/`language`/`code`, невалидное имя, неизвестный язык, код > 1 MB, невалидный TTL |
| 409 | Функция с таким именем уже существует у этого пользователя |
---
### POST /functions — Создать функцию из zip-архива (multipart)
Альтернативный способ: передать архив напрямую при создании функции.
```bash
curl -X POST https://fission.kube5s.ru/console/api/functions \
-H "X-Auth-Token: user@domain.com" \
-F "name=my-fn" \
-F "language=python" \
-F "entrypoint=main.handler" \
-F "archive=@my-function.zip"
```
**Параметры формы (multipart/form-data):**
| Поле | Тип | Обязательно | Описание |
|------|-----|:-----------:|---------|
| `name` | string | ✓ | Имя функции |
| `language` | string | ✓* | Язык (`python`, `nodejs`, `go`, `php`, `ruby`) — или `environment` |
| `environment` | string | ✓* | Явное имя environment (вместо `language`) |
| `archive` | file | ✓ | zip-архив с кодом. Максимум 100 KB |
| `entrypoint` | string | — | Точка входа |
| `route` | string | — | HTTP-маршрут |
| `methods` | string | — | HTTP-методы через запятую (`GET,POST`) |
| `timeout` | string | — | Таймаут в секундах |
| `ttl` | string | — | Время жизни: `15m`, `1h`, `2d` и т.д. |
> Архив должен быть валидным zip (magic bytes `PK`). Максимальный суммарный распакованный размер — 100 KB (защита от zip bomb).
**Ответ 201:**
```json
{
"name": "my-fn",
"namespace": "fission-a3f9c1b2d4e6f8a1",
"environment": "python-env",
"route": "/a3f9c1b2d4e6/my-fn",
"source_type": "archive"
}
```
---
### GET /functions — Список функций
```bash
curl https://fission.kube5s.ru/console/api/functions \
-H "X-Auth-Token: user@domain.com"
```
**Ответ 200** — массив сырых K8s объектов типа `Function`. Новый пользователь → `[]`.
Возвращает **только функции текущего пользователя**.
---
### GET /functions/{name} — Описание функции
```bash
curl https://fission.kube5s.ru/console/api/functions/my-fn \
-H "X-Auth-Token: user@domain.com"
```
**Ответ 200:**
```json
{
"name": "my-fn",
"namespace": "fission-a3f9c1b2d4e6f8a1",
"environment": "nodejs-env",
"package": "my-fn-pkg",
"entrypoint": "main",
"timeout": 60,
"created_at": "2026-05-01T10:00:00Z",
"updated_at": "2026-05-01T12:00:00Z",
"code": "module.exports = async function(ctx) { ... }",
"source_type": "code",
"archive_filename": "",
"route": "/a3f9c1b2d4e6/my-fn",
"methods": ["GET", "POST"],
"raw": {}
}
```
> `code` — исходный код (если хранится как literal). Для функций из архива может быть пустым.
> `source_type` — `"code"` или `"archive"`.
> `raw` — полный K8s объект Function.
**Ошибки:**
| Код | Причина |
|-----|---------|
| 404 | Функция не существует или принадлежит другому пользователю |
---
### POST /functions/{name}/invoke — Вызов функции
```bash
curl -X POST https://fission.kube5s.ru/console/api/functions/my-fn/invoke \
-H "X-Auth-Token: user@domain.com" \
-H "Content-Type: application/json" \
-d '{}'
```
**Ответ 200:**
```json
{
"status": 200,
"latency_ms": 42,
"response_raw": "hello"
}
```
> `response_raw` — тело ответа функции как строка.
> `status` — HTTP-статус ответа функции.
> `latency_ms` — время выполнения в миллисекундах.
> Cold start (первый вызов после создания) может занять **10-60 секунд** — Pod создаётся и прогревается. Последующие вызовы быстрые.
**Ошибки:**
| Код | Причина |
|-----|---------|
| 404 | Функция не существует или принадлежит другому пользователю |
| 502 | Fission router недоступен или функция завершилась с timeout |
---
### PUT /functions/{name}/code — Обновить код функции ★
Обновляет код существующей функции. Создаётся новый Package, executor подхватывает его при следующем вызове.
```bash
curl -X PUT https://fission.kube5s.ru/console/api/functions/my-fn/code \
-H "X-Auth-Token: user@domain.com" \
-H "Content-Type: application/json" \
-d '{
"code": "module.exports = async function(ctx) { return { status: 200, body: \"v2\" }; }"
}'
```
**Параметры:**
| Поле | Тип | Обязательно | Описание |
|------|-----|:-----------:|---------|
| `code` | string | ✓ | Новый исходный код |
| `timeout` | int64 | — | Новый таймаут в секундах |
**Ответ 200:**
```json
{
"updated": true,
"package": "my-fn-pkg-xxxxxx"
}
```
**Ошибки:**
| Код | Причина |
|-----|---------|
| 400 | `code` пустой или только пробелы |
| 404 | Функция не существует или принадлежит другому пользователю |
---
### PUT /functions/{name}/archive — Обновить архив функции ★
Обновляет функцию новым zip-архивом (multipart/form-data, поле `archive`).
```bash
curl -X PUT https://fission.kube5s.ru/console/api/functions/my-fn/archive \
-H "X-Auth-Token: user@domain.com" \
-F "archive=@my-function-v2.zip"
```
**Ответ 200:**
```json
{
"updated": true,
"package": "my-fn-pkg-xxxxxx"
}
```
---
### PUT /functions/{name}/timeout — Обновить таймаут функции ★
Обновляет только таймаут (и опционально entrypoint) без замены кода или архива.
```bash
curl -X PUT https://fission.kube5s.ru/console/api/functions/my-fn/timeout \
-H "X-Auth-Token: user@domain.com" \
-H "Content-Type: application/json" \
-d '{"timeout": 120}'
```
**Параметры:**
| Поле | Тип | Описание |
|------|-----|---------|
| `timeout` | int64 | Новый таймаут в секундах |
| `entrypoint` | string | Новая точка входа (опционально) |
**Ответ 200:**
```json
{
"updated": true,
"timeout": 120
}
```
---
### DELETE /functions/{name} — Удалить функцию
```bash
curl -X DELETE https://fission.kube5s.ru/console/api/functions/my-fn \
-H "X-Auth-Token: user@domain.com"
```
**Ответ 200:**
```json
{
"deleted": true,
"name": "my-fn",
"package": "my-fn-pkg"
}
```
> Удаляются также связанные HTTPTrigger, TimeTrigger и Package.
> Архив в S3 удаляется асинхронно.
> Если язык больше не используется ни одной функцией — environment Pod'ы убираются автоматически.
**Ошибки:**
| Код | Причина |
|-----|---------|
| 404 | Функция не существует или принадлежит другому пользователю |
> Повторное удаление той же функции → **404**.
---
## Прямой вызов по route — GET|POST /fn/{route}
Вызов функции напрямую по HTTP-маршруту без обёртки invoke. Ответ проксируется как есть — без JSON-обёртки.
```bash
curl https://fission.kube5s.ru/fn/a3f9c1b2d4e6/my-fn \
-H "X-Auth-Token: user@domain.com"
```
> Используйте этот endpoint когда нужно получить чистый HTTP-ответ функции, а не JSON-обёртку с `response_raw`.
> Метод запроса (GET/POST/…) проксируется без изменений.
---
## Time Triggers (расписание)
### GET /timetriggers — Список
```bash
curl https://fission.kube5s.ru/console/api/timetriggers \
-H "X-Auth-Token: user@domain.com"
```
Возвращает массив сырых K8s объектов TimeTrigger.
### POST /timetriggers — Создать
```bash
curl -X POST https://fission.kube5s.ru/console/api/timetriggers \
-H "X-Auth-Token: user@domain.com" \
-H "Content-Type: application/json" \
-d '{
"name": "my-cron",
"functionName": "my-fn",
"cron": "*/5 * * * *"
}'
```
**Параметры:**
| Поле | Тип | Обязательно | Описание |
|------|-----|:-----------:|---------|
| `name` | string | ✓ | Имя trigger'а |
| `functionName` | string | ✓ | Имя функции |
| `cron` | string | ✓ | Cron-выражение (стандартный формат) |
| `method` | string | — | HTTP-метод для вызова (по умолчанию `POST`) |
| `subpath` | string | — | Дополнительный путь |
**Ответ 201:**
```json
{
"name": "my-cron",
"namespace": "fission-a3f9c1b2d4e6f8a1",
"cron": "*/5 * * * *",
"method": "POST",
"subpath": "",
"function": "my-fn",
"raw": {}
}
```
### GET /timetriggers/{name} — Описание
**Ответ 200** — та же структура что и при создании.
### PUT /timetriggers/{name} — Обновить
**Ответ 200:**
```json
{
"updated": true,
"trigger": { ...та же структура... }
}
```
### DELETE /timetriggers/{name} — Удалить
**Ответ 200:**
```json
{
"deleted": true,
"name": "my-cron"
}
```
---
## AI-линтер архивов ★
Проверяет zip-архив на синтаксические ошибки до деплоя. Не создаёт функцию.
Поддерживаемые файлы: `.py`, `.js`, `.rb`, `.php`.
### POST /ai/lint-archive
```bash
curl -X POST https://fission.kube5s.ru/console/api/ai/lint-archive \
-H "X-Auth-Token: user@domain.com" \
-F "archive=@my-function.zip" \
-F "entrypoint=main.handler" \
-F "language=python"
```
**Параметры формы:**
| Поле | Описание |
|------|---------|
| `archive` | zip-архив (обязательно) |
| `entrypoint` | Точка входа `module.function` — проверяется что файл и функция существуют в архиве |
| `language` | Язык — проверяется что архив содержит файлы нужного расширения |
**Ответ 200:**
```json
{
"ok": true,
"results": [
{"file": "main.py", "ok": true},
{"file": "helper.py", "ok": false, "output": "SyntaxError: invalid syntax (helper.py, line 5)"},
{"file": "main.handler", "ok": true, "output": "entrypoint 'main.handler' найден"}
]
}
```
> `ok: false` в корне объекта означает что хотя бы один файл не прошёл проверку.
> `output` содержит вывод линтера — присутствует только при ошибке (для entrypoint — всегда).
**Ошибки:**
| Код | Причина |
|-----|---------|
| 400 | Нет поля `archive`, нет поддерживаемых файлов (.py/.js/.rb/.php) в архиве |
| 413 | Архив > 100 KB или суммарный распакованный размер > 100 KB (zip bomb protection) |
---
## TTL — Время жизни функции ★
Функция может быть создана с ограниченным временем жизни. После истечения TTL функция удаляется автоматически.
**Формат:** число + суффикс: `m` (минуты), `h` (часы), `d` (дни).
**Примеры:** `15m`, `1h`, `2d`, `12h`
```bash
curl -X POST .../functions \
-H "X-Auth-Token: user@domain.com" \
-H "Content-Type: application/json" \
-d '{
"name": "temp-fn",
"language": "python",
"code": "def main(event, context): return \"hi\"",
"ttl": "1h"
}'
```
В ответе будет поле `expires_at` (формат RFC3339):
```json
{
"name": "temp-fn",
"package": "temp-fn-pkg",
"httptrigger": "temp-fn-route",
"route": "/...",
"expires_at": "2026-05-06T14:00:00Z"
}
```
Невалидные значения TTL (`0d`, `-1h`, `99z`, `abc`) → **400**.
---
## HTTP-коды — сводная таблица
| Код | Значение |
|-----|---------|
| 200 | Успех (GET, DELETE, PUT) |
| 201 | Объект создан (POST /functions, POST /timetriggers) |
| 400 | Ошибка валидации параметров |
| 401 | Не авторизован (нет токена или < 6 символов) |
| 404 | Объект не найден (или чужой) |
| 405 | Неверный HTTP-метод |
| 409 | Конфликт (дубликат имени) |
| 413 | Тело слишком большое (код > 1 MB, архив > 100 KB) |
| 502 | Ошибка взаимодействия с Fission (router/executor недоступен) |
**Формат ошибки:**
```json
{ "error": "описание ошибки" }
```
---
## Поддерживаемые языки
| `language` | Расширение файла | Entrypoint по умолчанию |
|------------|-----------------|------------------------|
| `python` | `.py` | `main.main` |
| `nodejs` | `.js` | зависит от runtime |
| `go` | `.go` | зависит от runtime |
| `php` | `.php` | зависит от runtime |
| `ruby` | `.rb` | зависит от runtime |
---
## Примеры сценариев
### Быстрый старт (inline-код)
```bash
BASE="https://fission.kube5s.ru/console/api"
TOKEN="myuser@example.com"
# Создать функцию
curl -X POST "$BASE/functions" \
-H "X-Auth-Token: $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"hello","language":"python","code":"def main(event, context): return \"hello world\""}'
# Вызвать
curl -X POST "$BASE/functions/hello/invoke" \
-H "X-Auth-Token: $TOKEN" -H "Content-Type: application/json" -d '{}'
# Обновить код
curl -X PUT "$BASE/functions/hello/code" \
-H "X-Auth-Token: $TOKEN" -H "Content-Type: application/json" \
-d '{"code":"def main(event, context): return \"v2\""}'
# Удалить
curl -X DELETE "$BASE/functions/hello" -H "X-Auth-Token: $TOKEN"
```
### Создать из архива напрямую
```bash
curl -X POST "$BASE/functions" \
-H "X-Auth-Token: $TOKEN" \
-F "name=my-fn" \
-F "language=python" \
-F "entrypoint=main.handler" \
-F "archive=@my-fn.zip"
```
### Проверить архив перед деплоем
```bash
curl -X POST "$BASE/ai/lint-archive" \
-H "X-Auth-Token: $TOKEN" \
-F "archive=@my-fn.zip" \
-F "entrypoint=main.handler" \
-F "language=python"
```
### Создать временную функцию (исчезнет через 30 минут)
```bash
curl -X POST "$BASE/functions" \
-H "X-Auth-Token: $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"temp-fn","language":"nodejs","code":"module.exports = async () => ({status:200,body:\"tmp\"})","ttl":"30m"}'
```
### Настроить расписание
```bash
# Вызывать my-fn каждые 5 минут
curl -X POST "$BASE/timetriggers" \
-H "X-Auth-Token: $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"my-cron","functionName":"my-fn","cron":"*/5 * * * *"}'
```
+72
View File
@@ -0,0 +1,72 @@
# Console ↔ fission-src multitenant: compatibility check (2026-05-15)
## Что изменилось в fission-src (feature/multitenant)
| Изменение | Файл |
|---|---|
| Удалён старый partial RBAC | `deploy/executor-ns-watcher-rbac.yaml` |
| Добавлен полный RBAC для NSWatcher | `deploy/multitenant/rbac.yaml` |
| Добавлен `EnsureNamespaceSA` | `pkg/utils/serviceaccount.go` |
| NSWatcher вызывает `EnsureNamespaceSA` при обнаружении NS с `fission.io/managed=true` | `pkg/executor/multitenant/ns_watcher.go` |
| Добавлен тест NSWatcher с fake k8s | `pkg/utils/namespace_manager_test.go` |
---
## Что делает консоль при создании namespace
`SetupFissionNamespace` в `console/internal/fission/namespace.go`:
1. Создаёт Namespace с лейблами:
- `managed-by=fission-console`
- `fission.io/managed=true` ← триггер для NSWatcher
2. Создаёт ServiceAccounts: `fission-fetcher`, `fission-builder`
3. Создаёт RoleBindings с `cluster-admin` ClusterRole для всех Fission SA:
- `fission-executor`, `fission-router`, `fission-buildermgr`, `fission-kubewatcher`, `fission-timer`
- `fission-fetcher` (из fission NS + локально в user NS)
- `fission-builder` (из fission NS + локально в user NS)
---
## Взаимодействие с EnsureNamespaceSA
`EnsureNamespaceSA` вызывается NSWatcher **после** того как консоль создала NS.
Логика (в `setupSAAndRoleBindings`):
1. Создаёт/получает SA `fission-fetcher` → SA уже существует → `IsAlreadyExists` → OK
2. Для каждого permission из `fetcherCheck` вызывает `checkPermission` через `localsubjectaccessreviews`
3. Поскольку у `fission-fetcher` уже есть `cluster-admin` RoleBinding (создан консолью) →
**все проверки возвращают `exists=true`**`rules` остаётся пустым → Role и RoleBinding **не создаются**
**Итог: EnsureNamespaceSA является no-op если консоль уже настроила namespace. Никаких конфликтов.**
---
## Что нужно на кластере
Для работы NSWatcher нужен `deploy/multitenant/rbac.yaml` применён **один раз**:
```bash
kubectl apply -f ~/terra/fission-src/deploy/multitenant/rbac.yaml
```
Это даёт:
- `fission-executor``list/watch namespaces` (NSWatcher)
- `fission-router``list/watch namespaces` (NSWatcher)
- `fission-executor``create SA/Role/RoleBinding` в user NS (`fission-executor-sa-provisioner`)
Без этого RBAC `EnsureNamespaceSA` будет падать с Forbidden, но **консоль продолжит работать** — она создаёт SA/RoleBindings сама и не зависит от NSWatcher.
---
## Вердикт
| Сценарий | Статус |
|---|---|
| Новый NS создаётся через консоль | ✅ работает как раньше |
| NSWatcher обнаруживает NS по `fission.io/managed=true` | ✅ совместимо |
| `EnsureNamespaceSA` вызывается в уже настроенном NS | ✅ no-op, нет конфликтов |
| Старый NS (без нового fission-bundle) | ✅ консоль не зависит от NSWatcher |
| Сборка консоли (`go build ./...`) | ✅ BUILD OK |
**Код консоли менять не нужно.** Нужно только применить `deploy/multitenant/rbac.yaml` при деплое нового fission-bundle.
+349
View File
@@ -0,0 +1,349 @@
# Fission Multi-Tenant Porting Guide
> **Purpose:** This document exists so that any AI agent or engineer can fully
> understand what was changed to add multi-tenancy to this Fission fork, why
> each decision was made, and what needs to be ported when a new upstream
> Fission release arrives.
>
> Fork base: `github.com/fission/fission` tag `v1.22.0` (2025-12-16)
> Our branch: `feature/multitenant`
---
## 1. The Problem We Solved
In stock Fission v1.22.0 all resource namespaces must be listed in the
`FISSION_RESOURCE_NAMESPACES` environment variable **before** the process starts.
Adding a new tenant namespace requires:
1. Patching that env var on executor, router, buildermgr deployments
2. Triggering a rolling restart of all three components (~30 s downtime each)
At scale (hundreds of tenants created continuously) this causes a permanent
rolling-restart loop and cascading failures for existing users.
**Our solution:** hot namespace registration without pod restarts. Any platform
(console, operator, CI/CD) creates a Kubernetes Namespace with label
`fission.io/managed=true` — all three Fission components detect it within
milliseconds via k8s Watch and register it live.
---
## 2. Integration Contract (external platforms)
The entire contract between an external platform and Fission is a single label:
```yaml
apiVersion: v1
kind: Namespace
metadata:
name: tenant-abc123
labels:
fission.io/managed: "true"
```
No other coupling to Fission internals is required.
To remove a tenant namespace: delete the namespace or remove the label.
Current removal strategy is `track-only` (the manager records the event but does
not actively deregister — the executor types stop receiving events for deleted
resources naturally). `dispatch-remove` strategy exists in the model but is not
wired by default (see §8).
---
## 2a. How the Console (`../fission`) Integrates
The Fission Console (`github.com/naeel/fission`, package `console`) creates tenant
namespaces via `SetupFissionNamespace()` in
`console/internal/fission/namespace.go`.
That function does three things:
1. Creates the Namespace with two labels:
- `managed-by=fission-console` — console's own filter
- **`fission.io/managed=true`** — this is the NSWatcher trigger
2. Creates `fission-fetcher` and `fission-builder` ServiceAccounts in the new NS
3. Creates RoleBindings for all Fission system SAs (`fission-executor`,
`fission-router`, `fission-buildermgr`, etc.) using `cluster-admin` scoped to
the namespace
**The coupling is exactly one label.** The console does not call any Fission
internal API to register the namespace — it just sets `fission.io/managed=true`
and the NSWatcher in executor/router/buildermgr picks it up automatically within
~50ms.
**Before `v1.22.0-mt1` (today's deploy):** the console set the label but the
executor was running the official `ghcr.io/fission/fission-bundle:v1.22.0` image
which has no NSWatcher — so the label was silently ignored. Tenant namespaces
still worked because the console also created the SA/RoleBindings manually (step 2
and 3 above), so pool pods could start. But executor/router/buildermgr were not
dynamically aware of new namespaces — they relied on whatever was in
`FISSION_RESOURCE_NAMESPACES` at startup.
**After `v1.22.0-mt1`:** executor/router/buildermgr detect the label
automatically. The SA creation in `EnsureNamespaceSA` (our code in
`pkg/utils/serviceaccount.go`) now runs from the executor side as well — but since
the console already created the SA, `EnsureNamespaceSA` is a no-op (idempotent).
No conflict, no double work.
---
## 3. Backward Compatibility
`FISSION_RESOURCE_NAMESPACES` continues to work exactly as before. Namespaces
listed there are bootstrapped at startup with source `env` and do not require the
label. The NSWatcher layer adds **on top** of the existing mechanism — nothing
was removed.
---
## 4. File Map
### New files (did not exist in v1.22.0)
| File | Purpose | What breaks if removed |
|------|---------|------------------------|
| `pkg/utils/namespace_manager_model.go` | All types: `NamespaceRecord`, `NamespacePhase`, `NamespaceSource`, `NamespaceEvent`, `NamespaceRemovalStrategy`, `ManagedNamespaceWatcherConfig` | Everything — all other files import these types |
| `pkg/utils/namespace_manager.go` | `NamespaceManager` interface + `inMemoryNamespaceManager` implementation. `RunManagedNamespaceWatcher()` — the single entry point used by all three components. `NewNamespaceWatcherEventHandlers()` — k8s informer callbacks. `EnsureNamespaceSA` helper call site. | All NSWatcher functionality |
| `pkg/utils/namespace_manager_test.go` | Unit + integration tests for NamespaceManager | Tests only |
| `pkg/utils/namespace_manager_model_test.go` | Tests for model helpers | Tests only |
| `pkg/utils/serviceaccount.go` (was modified, `EnsureNamespaceSA` added at bottom) | `EnsureNamespaceSA(ctx, client, logger, ns)` — creates fission-fetcher SA/Role/RoleBinding in a new namespace idempotently | Pool pods in new namespaces fail to start (no SA to run fetcher) |
| `pkg/executor/multitenant/ns_watcher.go` | `StartNSWatcher()` — executor entry point. `registerNamespace()` — calls `AddNamespace` on global resolver + `EnsureNamespaceSA` + all executor types. | Executor never learns about new namespaces |
| `pkg/executor/multitenant/namespace_subscriber.go` | `NewNamespaceSubscriber()` — adapter from `NamespaceSubscriber` interface to executor `registerNamespace()` | Same as above |
| `pkg/executor/multitenant/ns_watcher_test.go` | Tests | Tests only |
| `pkg/executor/multitenant/namespace_subscriber_test.go` | Tests | Tests only |
| `pkg/router/ns_watcher.go` | `StartNSWatcher()` — router entry point, 5 lines | Router never learns about new namespaces |
| `pkg/router/namespace_subscriber.go` | `NewNamespaceSubscriber()` — adapter calling `HTTPTriggerSet.AddNamespace` | Same as above |
| `pkg/router/namespace_subscriber_test.go` | Tests | Tests only |
| `pkg/buildermgr/ns_watcher.go` | `StartNSWatcher()` — buildermgr entry point, 5 lines | Buildermgr never learns about new namespaces |
| `pkg/buildermgr/namespace_subscriber.go` | `NewNamespaceSubscriber()` — adapter calling `envWatcher.AddNamespace` + `pkgWatcher.AddNamespace` | Same as above |
| `pkg/buildermgr/namespace_subscriber_test.go` | Tests | Tests only |
| `deploy/multitenant/rbac.yaml` | ClusterRoles + ClusterRoleBindings for all three components (see §6) | Components crash at startup or fail to watch namespaces |
### Modified files (existed in v1.22.0, we changed them)
| File | What we added | What breaks if reverted |
|------|--------------|-------------------------|
| `pkg/utils/namespace.go` | `ManagedNamespaceLabelKey/Value` constants, `ManagedNamespaceLabelSelector()`, `IsManagedNamespace()`, `AddNamespace()` (thread-safe dedup), `Snapshot()` (sorted slice copy under read-lock), `SnapshotWithOptions()` | All callers of `Snapshot()` break — there are many; label constants used by informer filter |
| `pkg/utils/namespace_test.go` | Tests for new methods | Tests only |
| `pkg/utils/informer.go` | `NewSharedInformerFactoryForNamespaces(namespaces []string)` — creates informer factory filtered to a dynamic list of namespaces | Executor types cannot create per-NS informers for new namespaces |
| `pkg/executor/executor.go` | `StartNSWatcher(...)` call added after executor types are initialized | NSWatcher never starts in executor |
| `pkg/executor/executortype/executortype.go` | `AddNamespace(ctx, ns, mgr)` added to the `ExecutorType` interface | All three executor types must implement this; compilation fails |
| `pkg/executor/executortype/poolmgr/gpm.go` | `AddNamespace()` implementation — creates per-NS informer factory, pod lister, event handlers | poolmgr never picks up functions in new namespaces |
| `pkg/executor/executortype/poolmgr/poolpodcontroller.go` | Uses `Snapshot()` in runtime loop instead of static namespace list | Pool pods not created in new namespaces |
| `pkg/executor/executortype/newdeploy/newdeploymgr.go` | `AddNamespace()` implementation | newdeploy never picks up functions in new namespaces |
| `pkg/executor/executortype/container/containermgr.go` | `AddNamespace()` implementation | container executor never picks up functions in new namespaces |
| `pkg/router/router.go` | `StartNSWatcher(...)` call added | NSWatcher never starts in router |
| `pkg/router/httpTriggers.go` | `AddNamespace(ns string)` on `HTTPTriggerSet` — starts per-NS informers for HTTPTriggers and Functions | Router ignores HTTPTriggers in new namespaces |
| `pkg/router/functionReferenceResolver.go` | Uses `Snapshot()` in runtime loop | Router resolves functions only in statically-configured namespaces |
| `pkg/buildermgr/buildermgr.go` | `StartNSWatcher(...)` call added | NSWatcher never starts in buildermgr |
| `pkg/buildermgr/envwatcher.go` | `AddNamespace(ns string)` — starts per-NS Environment informer | Buildermgr ignores Environments in new namespaces |
| `pkg/buildermgr/pkgwatcher.go` | `AddNamespace(ns string)` — starts per-NS Package informer | Buildermgr ignores Packages in new namespaces |
| `pkg/storagesvc/archivePruner.go` | Uses `Snapshot()` instead of static namespace list | Archive pruner only cleans old namespaces |
| `.gitignore` | Added `*.token` | Minor — token files would be committed accidentally |
| `deploy/multitenant/rbac.yaml` | New file (see above) | — |
---
## 5. Data Flow: from label to HTTP 200
```
kubectl label ns tenant-abc123 fission.io/managed=true
k8s API server emits ADDED event on Namespace stream
▼ (within ~50ms)
utils.RunManagedNamespaceWatcher ← informer AddFunc
│ (all 3 components share this function)
NamespaceManager.DispatchAdd(ctx, "tenant-abc123")
├──► executor subscriber:
│ registerNamespace()
│ 1. DefaultNSResolver().AddNamespace("tenant-abc123")
│ 2. EnsureNamespaceSA(ctx, client, logger, "tenant-abc123")
│ └─ creates fission-fetcher SA + Role + RoleBinding
│ 3. poolmgr.AddNamespace("tenant-abc123")
│ └─ creates per-NS InformerFactory, pod lister, handlers
│ 4. newdeploy.AddNamespace("tenant-abc123")
│ 5. container.AddNamespace("tenant-abc123")
├──► router subscriber:
│ HTTPTriggerSet.AddNamespace("tenant-abc123")
│ └─ starts watching HTTPTriggers + Functions in that NS
└──► buildermgr subscriber:
envWatcher.AddNamespace("tenant-abc123")
pkgWatcher.AddNamespace("tenant-abc123")
└─ starts watching Environments + Packages in that NS
User creates: fission env create --namespace tenant-abc123 ...
fission fn create --namespace tenant-abc123 ...
fission httptrigger create --namespace tenant-abc123 ...
Router picks up HTTPTrigger → resolves Function → cold-starts pod in tenant-abc123
HTTP 200 ← function response
```
---
## 6. RBAC Explained
Three ClusterRoles in `deploy/multitenant/rbac.yaml`:
### `fission-executor-ns-watcher` (also for router: `fission-router-ns-watcher`)
```
namespaces: list, watch
```
Without this: informer fails to start with "Forbidden" — components never learn
about new namespaces.
### `fission-executor-sa-provisioner`
```
serviceaccounts: get, list, watch, create, update, patch
events: create
localsubjectaccessreviews: create
roles: get, list, watch, create, update, patch
rolebindings: get, list, watch, create, update, patch
```
Why `events.create`: Kubernetes forbids creating a Role that grants permissions
the caller does not currently hold. Since `fission-fetcher` gets `events.create`
in the Role we create for it, `fission-executor` must hold `events.create` itself
to be allowed to create that Role. This is a k8s RBAC escalation prevention rule.
Why `localsubjectaccessreviews.create`: `EnsureNamespaceSA` calls
`setupSAAndRoleBindings` which first checks if the SA already has each permission
before creating it — that check uses a `LocalSubjectAccessReview`.
---
## 7. Key Design Decisions and Why
### Decision: pub/sub via `NamespaceManager`, not direct calls
**Why not:** simply call `poolmgr.AddNamespace`, `router.AddNamespace` etc.
directly from a shared goroutine.
**Why pub/sub:** executor, router and buildermgr run as separate processes
(different pod). Each process has its own copy of the watcher. Subscribers are
registered in-process. This pattern makes each component fully self-contained and
testable in isolation. No cross-process coupling.
### Decision: `fission.io/managed=true` label as the only trigger
**Why not:** watch all namespaces, or use a CRD, or use annotations.
**Why label:** labels are the idiomatic k8s way to select resources. A label
selector in the informer factory (`fission.io/managed=true`) means the informer
only receives events for labeled namespaces — zero overhead for the hundreds of
system namespaces.
### Decision: `EnsureNamespaceSA` in executor, not in a separate operator
**Why:** the SA must exist before the first pool pod starts. The executor is
already in the hot path — it processes the NS event and then immediately triggers
pod scheduling. Doing it in a separate controller would introduce a race. Doing it
in the executor keeps the lifecycle coupled correctly.
### Decision: `NamespaceRemovalStrategy = track-only` (not `dispatch-remove`)
**Why:** removing a namespace in k8s is already an irreversible event — all
resources inside are cascade-deleted by k8s. The executor types detect the pod
deletions themselves. Dispatching an explicit "remove" to all subscribers would
require each subscriber to implement a cleanup path — added complexity for zero
operational benefit in our use case. Can be switched per-component via
`ManagedNamespaceWatcherConfig.RemovalStrategy`.
### Decision: `AddNamespace` is idempotent (safe to call multiple times)
**Why:** k8s informers can deliver the same event more than once (resync). Every
`AddNamespace` call is a no-op if the namespace is already registered. No locks
held across the whole function — `NamespaceResolver.AddNamespace` uses a write
lock only for the map write, checks for existence first under the same lock.
### Decision: global `NamespaceResolver` (`DefaultNSResolver()`) updated once in executor
**Why:** `DefaultNSResolver().AddNamespace(ns)` is called exactly once in
`registerNamespace()` — before the executor types are called. Each executor type
does NOT call it themselves. This avoids a subtle race: if executor type A adds
the NS to the global resolver first, and executor type B checks the global resolver
as its dedup mechanism, B would see it as already registered and skip — even
though B has not actually processed it yet. The correct dedup is per executor type.
---
## 8. What Is NOT Done (deliberately deferred)
| Missing feature | Why deferred | File/interface to extend |
|----------------|--------------|--------------------------|
| `dispatch-remove` full wiring | Not needed for current use case | `ManagedNamespaceWatcherConfig.RemovalStrategy` — just switch the constant |
| Buildermgr ClusterRole in rbac.yaml | Buildermgr uses the same SA as executor in our deployment; check your setup | Add a third ClusterRole/Binding to `deploy/multitenant/rbac.yaml` |
| Helm chart integration | We apply rbac.yaml manually. A proper Helm chart would include these RBAC objects | `charts/fission-all/templates/` |
| Layer 2 (tenant isolation: per-NS network policy, resource quotas) | Out of scope for Layer 1 | Not started |
| Layer 3 (per-tenant auth, billing hooks) | Out of scope | Not started |
| `NamespaceManager.DispatchRemove` subscriber wiring | Each subscriber has a `RemoveFunc` stub returning nil | Implement per subscriber |
---
## 9. Porting to a New Upstream Version
When `github.com/fission/fission` releases v1.23 or later, follow this order:
1. **Check the upstream changelog** for any changes to:
- `pkg/utils/namespace.go` — if they renamed or refactored `NamespaceResolver`, our `AddNamespace`/`Snapshot` additions need to be reapplied
- `pkg/executor/executortype/executortype.go` — if they changed the `ExecutorType` interface, our `AddNamespace` method needs to be reapplied
- `pkg/router/httpTriggers.go` — if `HTTPTriggerSet` changed, our `AddNamespace` method on it needs to be reapplied
- `pkg/buildermgr/envwatcher.go`, `pkgwatcher.go` — same
- `pkg/utils/informer.go` — if the informer factory pattern changed
2. **Apply in this order** (each depends on the previous):
1. `pkg/utils/namespace_manager_model.go` — pure types, no deps on other changed files
2. `pkg/utils/namespace.go` additions (`AddNamespace`, `Snapshot`, label constants)
3. `pkg/utils/namespace_manager.go` — depends on model + namespace.go
4. `pkg/utils/serviceaccount.go` — add `EnsureNamespaceSA` at the bottom
5. `pkg/utils/informer.go` — add `NewSharedInformerFactoryForNamespaces`
6. `pkg/executor/executortype/executortype.go` — add `AddNamespace` to interface
7. Executor types: `poolmgr/gpm.go`, `newdeploy/newdeploymgr.go`, `container/containermgr.go` — implement `AddNamespace`
8. `pkg/executor/multitenant/` — copy the whole package as-is
9. `pkg/executor/executor.go` — add `StartNSWatcher` call
10. `pkg/router/httpTriggers.go` — add `AddNamespace` method on `HTTPTriggerSet`
11. `pkg/router/namespace_subscriber.go`, `pkg/router/ns_watcher.go` — copy as-is
12. `pkg/router/router.go` — add `StartNSWatcher` call
13. `pkg/buildermgr/envwatcher.go`, `pkgwatcher.go` — add `AddNamespace` method
14. `pkg/buildermgr/namespace_subscriber.go`, `pkg/buildermgr/ns_watcher.go` — copy as-is
15. `pkg/buildermgr/buildermgr.go` — add `StartNSWatcher` call
16. `pkg/storagesvc/archivePruner.go` — replace static namespace list with `Snapshot()`
17. `deploy/multitenant/rbac.yaml` — apply unchanged
3. **Run tests:**
```bash
go test ./pkg/utils/... ./pkg/executor/... ./pkg/router/... ./pkg/buildermgr/...
```
4. **Build and deploy:**
```bash
# on VM:
docker run --rm -v $PWD:/src -w /src golang:1.26-alpine \
sh -c 'go build -o /src/fission-bundle-bin ./cmd/fission-bundle/'
docker build -f Dockerfile.mt -t naeel/fission-bundle:vNEW_TAG .
docker push naeel/fission-bundle:vNEW_TAG
kubectl set image deployment/executor -n fission executor=naeel/fission-bundle:vNEW_TAG
kubectl set image deployment/router -n fission router=naeel/fission-bundle:vNEW_TAG
kubectl set image deployment/buildermgr -n fission buildermgr=naeel/fission-bundle:vNEW_TAG
```
5. **Run e2e test:**
```bash
bash ~/terra/fission/scripts/test_layer1.sh
# Expected: PASS=5 FAIL=0
```
---
## 10. Test Coverage
| Test file | What it covers |
|-----------|---------------|
| `pkg/utils/namespace_test.go` | `AddNamespace` dedup, `Snapshot` sorted output, `IsManagedNamespace` |
| `pkg/utils/namespace_manager_test.go` | `Bootstrap`, `DispatchAdd`/`Remove`/`Resync`, subscriber dispatch, `TestStartManagedNamespaceWatcherIntegration` — full k8s fake informer → subscriber pipeline |
| `pkg/utils/namespace_manager_model_test.go` | Model helpers, `Clone`, `IsActive`, `IsTerminal` |
| `pkg/executor/multitenant/ns_watcher_test.go` | `registerNamespace` with fake k8s client |
| `pkg/executor/multitenant/namespace_subscriber_test.go` | Subscriber adapter |
| `pkg/router/namespace_subscriber_test.go` | Router subscriber adapter |
| `pkg/buildermgr/namespace_subscriber_test.go` | Buildermgr subscriber adapter |
| `~/terra/fission/scripts/test_layer1.sh` | End-to-end: create labeled NS → executor registers it → create env/fn/trigger → call function → HTTP 200 |
+191
View File
@@ -0,0 +1,191 @@
# Fission — Краткий справочник команд
> Базовый URL: `https://fission.kube5s.ru/console/api`
> Токен передаётся через `X-Auth-Token: <token>` или `Authorization: Bearer <token>`
```bash
BASE="https://fission.kube5s.ru/console/api"
T="X-Auth-Token: mylogin@example.com" # demo: любая строка ≥6 символов
```
---
## Стандартные операции
### Функции
```bash
# Создать функцию
curl -X POST "$BASE/functions" -H "$T" -H "Content-Type: application/json" \
-d '{"name":"hello","language":"python","code":"def main(event, context): return \"hi\""}'
# Список функций
curl "$BASE/functions" -H "$T"
# Описание функции
curl "$BASE/functions/hello" -H "$T"
# Вызвать функцию
curl -X POST "$BASE/functions/hello/invoke" -H "$T" \
-H "Content-Type: application/json" -d '{}'
# Обновить код
curl -X PUT "$BASE/functions/hello/code" -H "$T" -H "Content-Type: application/json" \
-d '{"code":"def main(event, context): return \"v2\""}'
# Удалить функцию
curl -X DELETE "$BASE/functions/hello" -H "$T"
```
**Языки:** `python`, `nodejs`, `go`, `php`, `ruby`
**Правила имени:** строчные буквы, цифры, дефис; не начинается/не заканчивается дефисом; максимум 57 символов.
---
### Environments
```bash
# Список environments
curl "$BASE/environments" -H "$T"
```
---
### Packages
```bash
# Список пакетов
curl "$BASE/packages" -H "$T"
```
---
### HTTP Triggers
```bash
# Список HTTP triggers
curl "$BASE/httptriggers" -H "$T"
```
---
### Time Triggers (cron)
```bash
# Создать cron
curl -X POST "$BASE/timetriggers" -H "$T" -H "Content-Type: application/json" \
-d '{"name":"my-cron","functionName":"hello","cron":"*/5 * * * *"}'
# Список
curl "$BASE/timetriggers" -H "$T"
# Обновить
curl -X PUT "$BASE/timetriggers/my-cron" -H "$T" -H "Content-Type: application/json" \
-d '{"cron":"0 * * * *"}'
# Удалить
curl -X DELETE "$BASE/timetriggers/my-cron" -H "$T"
```
---
### Прямой вызов по route
```bash
# Вызов без JSON-обёртки (чистый HTTP)
curl "https://fission.kube5s.ru/fn/<route>" -H "$T"
```
---
## Наши расширения
### Создание из zip-архива
```bash
# Создать функцию из архива напрямую
curl -X POST "$BASE/functions" -H "$T" \
-F "name=my-fn" -F "language=python" -F "entrypoint=main.handler" \
-F "archive=@my-function.zip"
# Обновить функцию новым архивом
curl -X PUT "$BASE/functions/my-fn/archive" -H "$T" \
-F "archive=@my-function-v2.zip"
```
---
### TTL — самоуничтожающиеся функции
```bash
# Функция исчезнет через 1 час
curl -X POST "$BASE/functions" -H "$T" -H "Content-Type: application/json" \
-d '{"name":"temp","language":"nodejs","code":"module.exports=async()=>({status:200,body:\"ok\"})","ttl":"1h"}'
```
Форматы TTL: `15m`, `2h`, `1d`, `7d`
---
### AI: проверить архив перед деплоем
```bash
curl -X POST "$BASE/ai/lint-archive" -H "$T" \
-F "archive=@my-fn.zip" \
-F "language=python" \
-F "entrypoint=main.handler"
```
Ответ:
```json
{
"ok": true,
"results": [{"file": "main.py", "ok": true}]
}
```
Поддерживает: `.py`, `.js`, `.rb`, `.php`
---
### Обновить только таймаут
```bash
curl -X PUT "$BASE/functions/hello/timeout" -H "$T" -H "Content-Type: application/json" \
-d '{"timeout": 120}'
```
---
### Статус namespace
```bash
# Готовность namespace (stages: создан → RBAC → control-plane)
curl "$BASE/ns/status" -H "$T"
```
---
### Аутентификация / получить namespace
```bash
curl -X POST "$BASE/auth" -H "Content-Type: application/json" \
-d '{"token":"mylogin@example.com"}'
# → {"ok":true,"namespace":"fission-a3f9c1b2...","email":"..."}
```
---
## HTTP-коды
| Код | Значение |
|-----|---------|
| 200 | OK |
| 201 | Создано |
| 400 | Ошибка валидации |
| 401 | Нет/невалидный токен |
| 404 | Не найдено (или чужое) |
| 409 | Уже существует |
| 413 | Слишком большой код/архив |
| 502 | Fission внутренняя ошибка |
@@ -0,0 +1,231 @@
# Мультитенантный Fission: сводная архитектура и инженерная логика
> Дата: 2026-05-15
> Контекст: форк Fission v1.22.0, ветка `feature/multitenant`
> Статус: реализовано, тесты зелёные
---
## Зачем это было нужно
Стандартный Fission требует, чтобы все namespace-ы, в которых живут функции,
были перечислены в переменной окружения `FISSION_RESOURCE_NAMESPACES` **до старта**
процессов. Добавление нового namespace = rolling restart всех компонентов (executor,
router, buildermgr). На сотнях тенантов — постоянный restart loop, каскадные сбои.
Наша задача: добавить новый tenant (namespace) без какого-либо рестарта.
---
## Концепция решения
Единственный public contract для внешних систем — label на Namespace:
```yaml
apiVersion: v1
kind: Namespace
metadata:
name: tenant-abc123
labels:
fission.io/managed: "true"
```
Никакого другого coupling с Fission internals не требуется.
После появления namespace с этим label Fission автоматически:
1. Регистрирует namespace во всех компонентах (executor, router, buildermgr)
2. Создаёт SA `fission-fetcher` и необходимый RBAC в namespace
3. Подключает informer factory для CRD (Functions, Environments, HTTPTriggers и т.д.)
4. Тенант может деплоить функции без задержки
---
## Архитектурная карта изменений
```
Kubernetes Namespace API
│ watch: label fission.io/managed=true
utils.RunManagedNamespaceWatcher(...)
│ (shared utility, один и тот же вызов из трёх компонентов)
utils.NamespaceManager (interface)
├─ Bootstrap(envNamespaces) ← уже существующие NS при старте
├─ DispatchAdd(ns) ← новый NS от watcher
└─ DispatchRemove(ns) ← NS удалён (track-only)
NamespaceSubscriber.OnNamespaceAdd(...)
┌───────────┼───────────┐
▼ ▼ ▼
executor router buildermgr
│ │ │
registerNS AddNS(ts) envw+pkgw
│ .AddNamespace
├─ DefaultNSResolver().AddNamespace(ns) ← thread-safe, dedup
├─ EnsureNamespaceSA(ctx, client, log, ns) ← SA + RBAC provisioning
└─ et.AddNamespace(ns, mgr) ← для каждого executor type
```
---
## Ключевые файлы
| Файл | Роль |
|------|------|
| `pkg/utils/namespace.go` | `NamespaceResolver` — хранит список NS, thread-safe Snapshot/AddNamespace |
| `pkg/utils/namespace_manager.go` | `NamespaceManager` — lifecycle, subscribers, event dispatch |
| `pkg/utils/namespace_manager_model.go` | Типы: Record, Phase, Event, Source, Summary |
| `pkg/utils/serviceaccount.go` | `EnsureNamespaceSA` — создаёт fission-fetcher SA/Role/RoleBinding |
| `pkg/executor/multitenant/ns_watcher.go` | Executor NSWatcher + `registerNamespace` |
| `pkg/executor/multitenant/namespace_subscriber.go` | Executor subscriber adapter |
| `pkg/router/ns_watcher.go` | Router NSWatcher (1 строка, через shared utility) |
| `pkg/router/namespace_subscriber.go` | Router subscriber adapter |
| `pkg/buildermgr/ns_watcher.go` | BuilderMgr NSWatcher (1 строка, через shared utility) |
| `pkg/buildermgr/namespace_subscriber.go` | BuilderMgr subscriber adapter |
| `deploy/multitenant/rbac.yaml` | ClusterRole/ClusterRoleBinding для всех трёх компонентов |
---
## Инженерные решения и почему именно так
### 1. Snapshot API вместо прямого чтения map
**Проблема:** `NamespaceResolver.FissionResourceNS` — mutable map, защищённая mutex
только на запись. Читатели в разных горутинах обращались к ней напрямую — data race.
**Решение:** `Snapshot() []string` — под read lock копирует map в sorted slice.
Потребители итерируют по стабильной копии, безопасно даже при конкурентных `AddNamespace`.
**Почему slice а не map:** потребителям нужен обход, а не lookup. Sorted slice даёт
детерминированный порядок — важно для тестов и для startup factory generation.
### 2. NamespaceManager как event bus
**Проблема:** каждый компонент реализовывал свой namespace watcher с нуля —
дублирование кода watcher setup, event handlers, deduplication, logging.
**Решение:** единый `utils.NamespaceManager` + `NamespaceSubscriber` interface.
Компонент реализует только `OnNamespaceAdd/Remove/Resync`, всё остальное — shared utility.
Это сократило `router/ns_watcher.go` до **5 строк**, `buildermgr/ns_watcher.go` до **5 строк**.
### 3. EnsureNamespaceSA — одно место, один вызов
**Проблема:** при динамической регистрации нового NS executor пытался создать pool pod,
но SA `fission-fetcher` ещё не существовал → `FailedCreate`, pod не стартует.
**Решение:** в `registerNamespace` (executor) вызывается `utils.EnsureNamespaceSA`
**до** вызова `et.AddNamespace`. SA всегда существует к моменту создания первого pod.
**Важно:** `EnsureNamespaceSA` — идемпотентная. Повторный вызов = safe no-op.
### 4. Buildermgr dedup bug
**Проблема:** `buildermgr.StartNSWatcher` при добавлении NS вызывал `envw.AddNamespace`
и `pkgw.AddNamespace`. Но глобальный `DefaultNSResolver().AddNamespace()` вызывался
внутри каждого watcher — dedup срабатывал после первого и блокировал второй.
**Решение:** `buildermgr/namespace_subscriber.go` вызывает `DefaultNSResolver().AddNamespace()`
один раз в `registerBuilderNamespace`, а затем оба watcher добавляют NS независимо.
### 5. Router informer maps — guard против nil panic
**Проблема:** в `router/httpTriggers.go` `AddNamespace` мог вызываться до инициализации
внутренних informer maps → nil pointer dereference.
**Решение:** добавлена explicit проверка nil перед операцией, с логом предупреждения.
---
## RBAC — что и почему
`deploy/multitenant/rbac.yaml` содержит три ClusterRole:
### fission-executor-ns-watcher
```
namespaces: list, watch
```
Нужен executor для регистрации Namespace informer. Без этого NSWatcher не стартует.
### fission-router-ns-watcher
```
namespaces: list, watch
```
То же для router.
### fission-executor-sa-provisioner
```
serviceaccounts: get, list, watch, create, update, patch
roles: get, list, watch, create, update, patch
rolebindings: get, list, watch, create, update, patch
events: create
authorization.k8s.io/localsubjectaccessreviews: create
```
Нетривиальные пункты:
- **events:create** — Kubernetes запрещает создавать `Role`, выдающую право,
которого нет у создающего субъекта. `fission-fetcher` получает `events:create`,
значит executor тоже должен его иметь.
- **localsubjectaccessreviews:create** — `setupSAAndRoleBindings` проверяет
существующие права через LSAR перед созданием Role. Без этого — 403.
---
## Что НЕ изменилось (backward compatibility)
- `FISSION_RESOURCE_NAMESPACES` env var работает как раньше — namespace-ы из него
регистрируются при старте через `Bootstrap()`.
- Существующие tenant namespace-ы, добавленные через env var, не нуждаются в label.
- Поведение функций, HTTP-триггеров, builder — неизменно.
- Helm chart стандартный; RBAC применяется отдельно: `kubectl apply -f deploy/multitenant/rbac.yaml`.
---
## Тест-сценарий (Layer 1)
Проверяет сквозной сценарий без рестарта:
1. Создать namespace `l1-test-XXXXX`
2. Добавить label `fission.io/managed=true`
3. Подождать, пока executor зарегистрирует NS (лог `registered namespace`)
4. Создать Environment + Function + HTTPTrigger в namespace
5. Вызвать функцию через router — ожидаемый ответ `200 OK`
Все шаги (PASS=5 FAIL=0) проходят стабильно после полного RBAC fix.
---
## Порядок деплоя нового форка
```bash
# 1. Применить RBAC (один раз на кластер)
kubectl apply -f deploy/multitenant/rbac.yaml
# 2. Деплоить fission-bundle с нашим образом
# (helm upgrade или kubectl apply с новым image tag)
# 3. Создать tenant
kubectl create namespace tenant-abc123
kubectl label namespace tenant-abc123 fission.io/managed=true
# 4. Готово. Можно деплоить функции в tenant-abc123.
```
---
## Направления дальнейшей работы
1. **e2e тесты** — автоматизированный `test_layer1.sh`-подобный тест в Go
2. **Helm chart** — включить `deploy/multitenant/rbac.yaml` как условный template
3. **Мониторинг** — expose namespace lifecycle events в metrics (Prometheus)
4. **Remove lifecycle** — сейчас при удалении NS стратегия `track-only`;
нужен `dispatch-remove` + cleanup informers
5. **Feature branch portability** — при выходе Fission 1.23 сделать rebase
этой ветки поверх нового upstream тега
+19 -2
View File
@@ -76,6 +76,8 @@ type (
podSpecPatch *apiv1.PodSpec
envWatchInformer map[string]k8sCache.SharedIndexInformer
enableOwnerReferences bool
// nsCancels holds per-namespace context cancel functions.
nsCancels map[string]context.CancelFunc
}
)
@@ -111,8 +113,8 @@ func makeEnvironmentWatcher(
podSpecPatch: podSpecPatch,
envWatchInformer: utils.GetInformersForNamespaces(fissionClient, time.Minute*30, fv1.EnvironmentResource),
enableOwnerReferences: utils.IsOwnerReferencesEnabled(),
nsCancels: make(map[string]context.CancelFunc),
}
err := envWatcher.EnvWatchEventHandlers(ctx)
if err != nil {
return nil, err
@@ -540,6 +542,21 @@ func (envw *environmentWatcher) AddNamespace(ctx context.Context, ns string, mgr
envw.envWatchInformer[ns] = envInf
mgr.AddInformers(ctx, map[string]k8sCache.SharedIndexInformer{ns: envInf})
factory.Start(ctx.Done())
// Create a per-namespace cancellable context for informer lifecycle.
nsCtx, nsCancel := context.WithCancel(ctx)
envw.nsCancels[ns] = nsCancel
factory.Start(nsCtx.Done())
envw.logger.Info("buildermgr.envWatcher.AddNamespace: done", zap.String("namespace", ns))
}
// RemoveNamespace deregisters a namespace from the environment watcher.
func (envw *environmentWatcher) RemoveNamespace(ns string) {
if cancel, ok := envw.nsCancels[ns]; ok {
cancel()
delete(envw.nsCancels, ns)
}
delete(envw.envWatchInformer, ns)
envw.logger.Info("buildermgr.envWatcher.RemoveNamespace: cleaned up", zap.String("namespace", ns))
}
+25
View File
@@ -11,10 +11,18 @@ type builderEnvNamespaceAdder interface {
AddNamespace(ctx context.Context, ns string, mgr manager.Interface)
}
type builderEnvNamespaceRemover interface {
RemoveNamespace(ns string)
}
type builderPkgNamespaceAdder interface {
AddNamespace(ctx context.Context, ns string, mgr manager.Interface)
}
type builderPkgNamespaceRemover interface {
RemoveNamespace(ns string)
}
func NewNamespaceSubscriber(envw builderEnvNamespaceAdder, pkgw builderPkgNamespaceAdder, mgr manager.Interface) utils.NamespaceSubscriber {
return utils.NamespaceSubscriberFuncs{
SubscriberName: "buildermgr",
@@ -22,6 +30,10 @@ func NewNamespaceSubscriber(envw builderEnvNamespaceAdder, pkgw builderPkgNamesp
registerBuilderNamespace(ctx, record.Name, envw, pkgw, mgr)
return nil
},
RemoveFunc: func(ctx context.Context, record utils.NamespaceRecord) error {
deregisterBuilderNamespace(record.Name, envw, pkgw)
return nil
},
ResyncFunc: func(ctx context.Context, record utils.NamespaceRecord) error {
registerBuilderNamespace(ctx, record.Name, envw, pkgw, mgr)
return nil
@@ -41,3 +53,16 @@ func registerBuilderNamespace(ctx context.Context, namespace string, envw builde
pkgw.AddNamespace(ctx, namespace, mgr)
}
}
func deregisterBuilderNamespace(namespace string, envw builderEnvNamespaceAdder, pkgw builderPkgNamespaceAdder) {
if namespace == "" {
return
}
utils.DefaultNSResolver().RemoveNamespace(namespace)
if r, ok := envw.(builderEnvNamespaceRemover); ok {
r.RemoveNamespace(namespace)
}
if r, ok := pkgw.(builderPkgNamespaceRemover); ok {
r.RemoveNamespace(namespace)
}
}
+3 -1
View File
@@ -25,7 +25,9 @@ func StartNSWatcher(
pkgw *packageWatcher,
mgr manager.Interface,
) {
_, err := utils.RunManagedNamespaceWatcher(ctx, logger, kubeClient, mgr, utils.NewDefaultManagedNamespaceWatcherConfig("buildermgr.NSWatcher", NewNamespaceSubscriber(envw, pkgw, mgr)))
config := utils.NewDefaultManagedNamespaceWatcherConfig("buildermgr.NSWatcher", NewNamespaceSubscriber(envw, pkgw, mgr))
config.RemovalStrategy = utils.NamespaceRemovalStrategyDispatchRemove
_, err := utils.RunManagedNamespaceWatcher(ctx, logger, kubeClient, mgr, config)
if err != nil {
logger.Error("buildermgr.NSWatcher: BootstrapAndDispatch failed", zap.Error(err))
}
+21 -2
View File
@@ -49,6 +49,8 @@ type (
pkgInformer map[string]k8sCache.SharedIndexInformer
storageSvcUrl string
buildCache *cache.Cache[crd.CacheKeyUR, *fv1.Package]
// nsCancels holds per-namespace context cancel functions.
nsCancels map[string]context.CancelFunc
}
)
@@ -64,6 +66,7 @@ func makePackageWatcher(logger *zap.Logger, fissionClient versioned.Interface, k
pkgInformer: pkgInformer,
storageSvcUrl: storageSvcUrl,
buildCache: cache.MakeCache[crd.CacheKeyUR, *fv1.Package](0, 0),
nsCancels: make(map[string]context.CancelFunc),
}
return pkgw
}
@@ -363,7 +366,23 @@ func (pkgw *packageWatcher) AddNamespace(ctx context.Context, ns string, mgr man
ns + "/pkg": pkgInf,
ns + "/pod": podInf,
})
fissionFactory.Start(ctx.Done())
podFactory.Start(ctx.Done())
// Create a per-namespace cancellable context for informer lifecycle.
nsCtx, nsCancel := context.WithCancel(ctx)
pkgw.nsCancels[ns] = nsCancel
fissionFactory.Start(nsCtx.Done())
podFactory.Start(nsCtx.Done())
pkgw.logger.Info("buildermgr.pkgWatcher.AddNamespace: done", zap.String("namespace", ns))
}
// RemoveNamespace deregisters a namespace from the package watcher.
func (pkgw *packageWatcher) RemoveNamespace(ns string) {
if cancel, ok := pkgw.nsCancels[ns]; ok {
cancel()
delete(pkgw.nsCancels, ns)
}
delete(pkgw.pkgInformer, ns)
delete(pkgw.podInformer, ns)
pkgw.logger.Info("buildermgr.pkgWatcher.RemoveNamespace: cleaned up", zap.String("namespace", ns))
}
+5
View File
@@ -347,6 +347,11 @@ func StartExecutor(ctx context.Context, clientGen crd.ClientGeneratorInterface,
executorTypes[ndm.GetTypeName(ctx)] = ndm
executorTypes[cnm.GetTypeName(ctx)] = cnm
// Pre-populate DefaultNSResolver with managed (labeled) namespaces so that
// AdoptExistingResources and CleanupOldExecutorObjects cover the full tenant NS set.
// Must run before the adopt/cleanup goroutines below. Non-fatal on error.
multitenant.PreRegisterManagedNamespaces(ctx, logger, kubernetesClient)
adoptExistingResources, _ := strconv.ParseBool(os.Getenv("ADOPT_EXISTING_RESOURCES"))
wg := &sync.WaitGroup{}
@@ -90,6 +90,10 @@ type (
objectReaperIntervalSecond time.Duration
enableOwnerReferences bool
// nsCancels holds per-namespace context cancel functions so informer
// factories started in AddNamespace can be stopped on RemoveNamespace.
nsCancels map[string]context.CancelFunc
}
)
@@ -132,8 +136,7 @@ func MakeContainer(
deplLister: make(map[string]appslisters.DeploymentLister),
deplListerSynced: make(map[string]k8sCache.InformerSynced),
svcLister: make(map[string]corelisters.ServiceLister),
svcListerSynced: make(map[string]k8sCache.InformerSynced),
svcListerSynced: make(map[string]k8sCache.InformerSynced), nsCancels: make(map[string]context.CancelFunc),
enableOwnerReferences: utils.IsOwnerReferencesEnabled(),
}
@@ -833,9 +836,36 @@ func (caaf *Container) AddNamespace(ctx context.Context, ns string, mgr manager.
return fmt.Errorf("AddNamespace %s (container): add function handler: %w", ns, err)
}
finformer.Start(ctx.Done())
cnmInformer.Start(ctx.Done())
// Create a per-namespace cancellable context so RemoveNamespace can stop
// these specific informer factories without affecting the whole process.
nsCtx, nsCancel := context.WithCancel(ctx)
caaf.nsCancels[ns] = nsCancel
finformer.Start(nsCtx.Done())
cnmInformer.Start(nsCtx.Done())
caaf.logger.Info("AddNamespace: done (container)", zap.String("namespace", ns))
return nil
}
// RemoveNamespace deregisters a namespace from the container executor.
// Cancels the per-namespace informer context and clears all lister maps so that
// a subsequent AddNamespace call will re-register the namespace correctly.
func (caaf *Container) RemoveNamespace(ctx context.Context, ns string) error {
if ns == "" {
return nil
}
caaf.logger.Info("RemoveNamespace: cleaning up namespace (container)", zap.String("namespace", ns))
if cancel, ok := caaf.nsCancels[ns]; ok {
cancel()
delete(caaf.nsCancels, ns)
}
delete(caaf.deplLister, ns)
delete(caaf.deplListerSynced, ns)
delete(caaf.svcLister, ns)
delete(caaf.svcListerSynced, ns)
return nil
}
@@ -74,4 +74,9 @@ type ExecutorType interface {
// starts watching Fission CRDs and K8s resources in it without a pod restart.
// Called when a Namespace with label fission.io/managed=true appears.
AddNamespace(ctx context.Context, ns string, mgr manager.Interface) error
// RemoveNamespace deregisters a namespace from the executor, cancelling its
// informer goroutines and clearing dedup state so that a re-add works correctly.
// Called when a Namespace with label fission.io/managed=true is removed.
RemoveNamespace(ctx context.Context, ns string) error
}
@@ -94,6 +94,10 @@ type (
objectReaperIntervalSecond time.Duration
enableOwnerReferences bool
// nsCancels holds per-namespace context cancel functions so informer
// factories started in AddNamespace can be stopped on RemoveNamespace.
nsCancels map[string]context.CancelFunc
}
)
@@ -140,8 +144,7 @@ func MakeNewDeploy(
deplLister: make(map[string]appslisters.DeploymentLister),
deplListerSynced: make(map[string]k8sCache.InformerSynced),
svcLister: make(map[string]corelisters.ServiceLister),
svcListerSynced: make(map[string]k8sCache.InformerSynced),
svcListerSynced: make(map[string]k8sCache.InformerSynced), nsCancels: make(map[string]context.CancelFunc),
enableOwnerReferences: utils.IsOwnerReferencesEnabled(),
}
@@ -949,9 +952,36 @@ func (deploy *NewDeploy) AddNamespace(ctx context.Context, ns string, mgr manage
return fmt.Errorf("AddNamespace %s (newdeploy): add environment handler: %w", ns, err)
}
finformer.Start(ctx.Done())
ndmInformer.Start(ctx.Done())
// Create a per-namespace cancellable context so RemoveNamespace can stop
// these specific informer factories without affecting the whole process.
nsCtx, nsCancel := context.WithCancel(ctx)
deploy.nsCancels[ns] = nsCancel
finformer.Start(nsCtx.Done())
ndmInformer.Start(nsCtx.Done())
deploy.logger.Info("AddNamespace: done (newdeploy)", zap.String("namespace", ns))
return nil
}
// RemoveNamespace deregisters a namespace from the newdeploy executor.
// Cancels the per-namespace informer context and clears all lister maps so that
// a subsequent AddNamespace call will re-register the namespace correctly.
func (deploy *NewDeploy) RemoveNamespace(ctx context.Context, ns string) error {
if ns == "" {
return nil
}
deploy.logger.Info("RemoveNamespace: cleaning up namespace (newdeploy)", zap.String("namespace", ns))
if cancel, ok := deploy.nsCancels[ns]; ok {
cancel()
delete(deploy.nsCancels, ns)
}
delete(deploy.deplLister, ns)
delete(deploy.deplListerSynced, ns)
delete(deploy.svcLister, ns)
delete(deploy.svcListerSynced, ns)
return nil
}
+37 -2
View File
@@ -98,6 +98,10 @@ type (
podSpecPatch *apiv1.PodSpec
objectReaperIntervalSecond time.Duration
// nsCancels holds per-namespace context cancel functions so informer
// factories started in AddNamespace can be stopped on RemoveNamespace.
nsCancels map[string]context.CancelFunc
}
request struct {
requestType
@@ -159,6 +163,7 @@ func MakeGenericPoolManager(ctx context.Context,
objectReaperIntervalSecond: time.Duration(executorUtils.GetObjectReaperInterval(logger, fv1.ExecutorTypePoolmgr, 5)) * time.Second,
podLister: make(map[string]corelisters.PodLister),
podListerSynced: make(map[string]k8sCache.InformerSynced),
nsCancels: make(map[string]context.CancelFunc),
}
for ns, informerFactory := range gpmInformerFactory {
gpm.podLister[ns] = informerFactory.Core().V1().Pods().Lister()
@@ -833,10 +838,40 @@ func (gpm *GenericPoolManager) AddNamespace(ctx context.Context, ns string, mgr
return fmt.Errorf("AddNamespace %s: register informers: %w", ns, err)
}
// Create a per-namespace cancellable context so RemoveNamespace can stop
// these specific informer factories without affecting the whole process.
nsCtx, nsCancel := context.WithCancel(ctx)
gpm.nsCancels[ns] = nsCancel
// Start the factories — they will begin syncing immediately.
finformer.Start(ctx.Done())
gpmInformer.Start(ctx.Done())
finformer.Start(nsCtx.Done())
gpmInformer.Start(nsCtx.Done())
gpm.logger.Info("AddNamespace: informers started for namespace", zap.String("namespace", ns))
return nil
}
// RemoveNamespace deregisters a namespace from the poolmgr executor.
// Cancels the per-namespace informer context and clears all lister maps so that
// a subsequent AddNamespace call will re-register the namespace correctly.
func (gpm *GenericPoolManager) RemoveNamespace(ctx context.Context, ns string) error {
if ns == "" {
return nil
}
gpm.logger.Info("RemoveNamespace: cleaning up namespace (poolmgr)", zap.String("namespace", ns))
// Stop informer factories for this namespace.
if cancel, ok := gpm.nsCancels[ns]; ok {
cancel()
delete(gpm.nsCancels, ns)
}
// Clear gpm-level lister maps so dedup passes on next AddNamespace.
delete(gpm.podLister, ns)
delete(gpm.podListerSynced, ns)
// Clear PoolPodController lister maps.
gpm.poolPodC.RemoveNamespace(ns)
return nil
}
@@ -528,3 +528,14 @@ func (p *PoolPodController) AddNamespaceInformers(
p.logger.Info("AddNamespaceInformers: registered informers for namespace", zap.String("namespace", ns))
return nil
}
// RemoveNamespace clears all per-namespace lister state in the PoolPodController.
// Called from GenericPoolManager.RemoveNamespace so the dedup check in AddNamespace
// will pass if the namespace is re-added later.
func (p *PoolPodController) RemoveNamespace(ns string) {
delete(p.envLister, ns)
delete(p.envListerSynced, ns)
delete(p.podLister, ns)
delete(p.podListerSynced, ns)
p.logger.Info("PoolPodController.RemoveNamespace: cleared lister state", zap.String("namespace", ns))
}
@@ -21,12 +21,16 @@ func NewNamespaceSubscriber(
return utils.NamespaceSubscriberFuncs{
SubscriberName: "executor",
AddFunc: func(ctx context.Context, record utils.NamespaceRecord) error {
registerNamespace(ctx, logger, kubernetesClient, record.Name, executorTypes, mgr)
return nil
return registerNamespace(ctx, logger, kubernetesClient, record.Name, executorTypes, mgr)
},
RemoveFunc: func(ctx context.Context, record utils.NamespaceRecord) error {
return deregisterNamespace(ctx, logger, record.Name, executorTypes)
},
ResyncFunc: func(ctx context.Context, record utils.NamespaceRecord) error {
registerNamespace(ctx, logger, kubernetesClient, record.Name, executorTypes, mgr)
return nil
// Reconciler calls this for namespaces in NamespacePhaseFailed.
// registerNamespace is idempotent: SA creation is a no-op if SA exists,
// executor type AddNamespace guards against duplicate informer creation.
return registerNamespace(ctx, logger, kubernetesClient, record.Name, executorTypes, mgr)
},
}
}
+68 -4
View File
@@ -62,8 +62,10 @@ package multitenant
import (
"context"
"errors"
"fmt"
"go.uber.org/zap"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
fv1 "github.com/fission/fission/pkg/apis/core/v1"
@@ -87,7 +89,9 @@ func StartNSWatcher(
executorTypes map[fv1.ExecutorType]executortype.ExecutorType,
mgr manager.Interface,
) {
_, err := utils.RunManagedNamespaceWatcher(ctx, logger, kubernetesClient, mgr, utils.NewDefaultManagedNamespaceWatcherConfig("multitenant.NSWatcher", NewNamespaceSubscriber(logger, kubernetesClient, executorTypes, mgr)))
config := utils.NewDefaultManagedNamespaceWatcherConfig("multitenant.NSWatcher", NewNamespaceSubscriber(logger, kubernetesClient, executorTypes, mgr))
config.RemovalStrategy = utils.NamespaceRemovalStrategyDispatchRemove
_, err := utils.RunManagedNamespaceWatcher(ctx, logger, kubernetesClient, mgr, config)
if err != nil {
logger.Error("multitenant.NSWatcher: BootstrapAndDispatch failed", zap.Error(err))
}
@@ -98,6 +102,10 @@ func StartNSWatcher(
// Each executor type uses its own internal state for deduplication instead of the
// global resolver, so all executor types receive the AddNamespace call regardless of
// iteration order.
//
// Returns an error if SA provisioning or any executor-type initialization fails.
// The error is propagated to the NamespaceSubscriber so the NamespaceManager can
// mark the namespace as NamespacePhaseFailed and the reconciler will retry automatically.
func registerNamespace(
ctx context.Context,
logger *zap.Logger,
@@ -105,14 +113,21 @@ func registerNamespace(
ns string,
executorTypes map[fv1.ExecutorType]executortype.ExecutorType,
mgr manager.Interface,
) {
) error {
// Update the global resolver once here. Each executor type must NOT call
// DefaultNSResolver().AddNamespace() for dedup — they have their own checks.
utils.DefaultNSResolver().AddNamespace(ns)
// Ensure fission-fetcher SA exists in the new namespace so pool pods can start.
utils.EnsureNamespaceSA(ctx, kubernetesClient, logger, ns)
registerExecutorTypes(ctx, logger, ns, executorTypes, mgr)
// A failure here means function pods will crash (no SA to pull fetcher image) —
// propagate so the reconciler retries until the API is available again.
if err := utils.EnsureNamespaceSA(ctx, kubernetesClient, logger, ns); err != nil {
return fmt.Errorf("EnsureNamespaceSA: %w", err)
}
if err := registerExecutorTypes(ctx, logger, ns, executorTypes, mgr); err != nil {
return fmt.Errorf("registerExecutorTypes: %w", err)
}
logger.Info("multitenant.NSWatcher: registered namespace", zap.String("namespace", ns))
return nil
}
func registerExecutorTypes(
@@ -135,3 +150,52 @@ func registerExecutorTypes(
}
return joinErr
}
// deregisterNamespace calls RemoveNamespace on every executor type for the given namespace.
// Called when a Namespace with label fission.io/managed=true is removed.
func deregisterNamespace(
ctx context.Context,
logger *zap.Logger,
ns string,
executorTypes map[fv1.ExecutorType]executortype.ExecutorType,
) error {
var joinErr error
for _, et := range executorTypes {
if err := et.RemoveNamespace(ctx, ns); err != nil {
logger.Error("multitenant.NSWatcher: RemoveNamespace failed",
zap.String("namespace", ns),
zap.Error(err),
)
joinErr = errors.Join(joinErr, err)
}
}
logger.Info("multitenant.NSWatcher: deregistered namespace", zap.String("namespace", ns))
return joinErr
}
// PreRegisterManagedNamespaces does a one-time synchronous List of all Namespaces
// labeled fission.io/managed=true and adds them to the global DefaultNSResolver.
//
// Called at executor startup BEFORE AdoptExistingResources and CleanupOldExecutorObjects
// so that adopt and cleanup cover managed (dynamic) namespaces, not just static ones
// from FISSION_RESOURCE_NAMESPACES. This closes the P2 race where old executor pods
// in managed NS were never adopted (causing unnecessary cold starts) and never cleaned
// (causing orphaned pod accumulation).
//
// Failure is non-fatal: a warning is logged and the executor proceeds with static NS only.
func PreRegisterManagedNamespaces(ctx context.Context, logger *zap.Logger, client kubernetes.Interface) {
nsList, err := client.CoreV1().Namespaces().List(ctx, metav1.ListOptions{
LabelSelector: utils.ManagedNamespaceLabelSelector(),
})
if err != nil {
logger.Warn("PreRegisterManagedNamespaces: failed to list managed namespaces; adopt/cleanup will use static NS only",
zap.Error(err))
return
}
for i := range nsList.Items {
utils.DefaultNSResolver().AddNamespace(nsList.Items[i].Name)
}
logger.Info("PreRegisterManagedNamespaces: pre-registered managed namespaces",
zap.Int("count", len(nsList.Items)))
}
@@ -46,6 +46,7 @@ func (f *fakeExecutorType) AddNamespace(ctx context.Context, ns string, mgr mana
f.lastNamespace = ns
return f.addErr
}
func (f *fakeExecutorType) RemoveNamespace(ctx context.Context, ns string) error { return nil }
var _ executortype.ExecutorType = (*fakeExecutorType)(nil)
+43 -12
View File
@@ -50,16 +50,18 @@ type HTTPTriggerSet struct {
*functionServiceMap
*mutableRouter
logger *zap.Logger
fissionClient versioned.Interface
kubeClient kubernetes.Interface
executor eclient.ClientInterface
resolver *functionReferenceResolver
triggers []fv1.HTTPTrigger
triggerInformer map[string]k8sCache.SharedIndexInformer
functions []fv1.Function
funcInformer map[string]k8sCache.SharedIndexInformer
informerMu sync.RWMutex
logger *zap.Logger
fissionClient versioned.Interface
kubeClient kubernetes.Interface
executor eclient.ClientInterface
resolver *functionReferenceResolver
triggers []fv1.HTTPTrigger
triggerInformer map[string]k8sCache.SharedIndexInformer
functions []fv1.Function
funcInformer map[string]k8sCache.SharedIndexInformer
informerMu sync.RWMutex
// nsCancels holds per-namespace context cancel functions for informer lifecycle.
nsCancels map[string]context.CancelFunc
updateRouterRequestChannel chan struct{}
tsRoundTripperParams *tsRoundTripperParams
isDebugEnv bool
@@ -84,6 +86,7 @@ func makeHTTPTriggerSet(logger *zap.Logger, fmap *functionServiceMap, fissionCli
svcAddrUpdateThrottler: actionThrottler,
unTapServiceTimeout: unTapServiceTimeout,
syncDebouncer: debounce.New(time.Millisecond * 20),
nsCancels: make(map[string]context.CancelFunc),
}
httpTriggerSet.triggerInformer = utils.GetInformersForNamespaces(fissionClient, time.Minute*30, fv1.HttpTriggerResource)
httpTriggerSet.funcInformer = utils.GetInformersForNamespaces(fissionClient, time.Minute*30, fv1.FunctionResource)
@@ -526,11 +529,39 @@ func (ts *HTTPTriggerSet) AddNamespace(ctx context.Context, ns string, mgr manag
ns + "/trigger": triggerInf,
ns + "/func": funcInf,
})
factory.Start(ctx.Done())
// Create a per-namespace cancellable context for informer lifecycle.
nsCtx, nsCancel := context.WithCancel(ctx)
ts.nsCancels[ns] = nsCancel
factory.Start(nsCtx.Done())
// Wait for cache to sync before rebuilding the router, so triggers are visible.
k8sCache.WaitForCacheSync(ctx.Done(), triggerInf.HasSynced, funcInf.HasSynced)
k8sCache.WaitForCacheSync(nsCtx.Done(), triggerInf.HasSynced, funcInf.HasSynced)
ts.logger.Info("router.AddNamespace: done", zap.String("namespace", ns))
ts.syncTriggers()
return nil
}
// RemoveNamespace deregisters a namespace from the router.
// Cancels the per-namespace informer context, removes informers from internal maps,
// and triggers a syncTriggers so stale routes are removed immediately.
func (ts *HTTPTriggerSet) RemoveNamespace(ns string) {
if ns == "" {
return
}
ts.logger.Info("router.RemoveNamespace: cleaning up namespace", zap.String("namespace", ns))
if cancel, ok := ts.nsCancels[ns]; ok {
cancel()
delete(ts.nsCancels, ns)
}
ts.informerMu.Lock()
delete(ts.triggerInformer, ns)
delete(ts.funcInformer, ns)
ts.informerMu.Unlock()
ts.syncTriggers()
ts.logger.Info("router.RemoveNamespace: done", zap.String("namespace", ns))
}
+10
View File
@@ -11,12 +11,22 @@ type routerNamespaceAdder interface {
AddNamespace(ctx context.Context, ns string, mgr manager.Interface) error
}
type routerNamespaceRemover interface {
RemoveNamespace(ns string)
}
func NewNamespaceSubscriber(ts routerNamespaceAdder, mgr manager.Interface) utils.NamespaceSubscriber {
return utils.NamespaceSubscriberFuncs{
SubscriberName: "router",
AddFunc: func(ctx context.Context, record utils.NamespaceRecord) error {
return registerRouterNamespace(ctx, record.Name, ts, mgr)
},
RemoveFunc: func(ctx context.Context, record utils.NamespaceRecord) error {
if r, ok := ts.(routerNamespaceRemover); ok {
r.RemoveNamespace(record.Name)
}
return nil
},
ResyncFunc: func(ctx context.Context, record utils.NamespaceRecord) error {
return registerRouterNamespace(ctx, record.Name, ts, mgr)
},
+3 -1
View File
@@ -25,7 +25,9 @@ func StartNSWatcher(
ts *HTTPTriggerSet,
mgr manager.Interface,
) {
_, err := utils.RunManagedNamespaceWatcher(ctx, logger, kubeClient, mgr, utils.NewDefaultManagedNamespaceWatcherConfig("router.NSWatcher", NewNamespaceSubscriber(ts, mgr)))
config := utils.NewDefaultManagedNamespaceWatcherConfig("router.NSWatcher", NewNamespaceSubscriber(ts, mgr))
config.RemovalStrategy = utils.NamespaceRemovalStrategyDispatchRemove
_, err := utils.RunManagedNamespaceWatcher(ctx, logger, kubeClient, mgr, config)
if err != nil {
logger.Error("router.NSWatcher: BootstrapAndDispatch failed", zap.Error(err))
}
+27 -28
View File
@@ -26,7 +26,6 @@ import (
"github.com/fission/fission/pkg/crd"
"github.com/fission/fission/pkg/generated/clientset/versioned"
"github.com/fission/fission/pkg/utils"
"github.com/fission/fission/pkg/utils/manager"
)
@@ -91,36 +90,36 @@ func (pruner *ArchivePruner) getOrphanArchives(ctx context.Context) {
archivesRefByPkgs := make([]string, 0)
var archiveID string
// get all pkgs from kubernetes
for _, namespace := range utils.DefaultNSResolver().Snapshot() {
pkgList, err := pruner.crdClient.CoreV1().Packages(namespace).List(ctx, metav1.ListOptions{})
if err != nil {
pruner.logger.Error("error getting package list from kubernetes", zap.Error(err))
return
}
// get all pkgs from kubernetes across ALL namespaces (including tenant namespaces)
// Fix: DefaultNSResolver().Snapshot() returns only fission-registered namespaces,
// not tenant namespaces (fission-*). Use NamespaceAll to scan everything.
pkgList, err := pruner.crdClient.CoreV1().Packages(metav1.NamespaceAll).List(ctx, metav1.ListOptions{})
if err != nil {
pruner.logger.Error("error getting package list from kubernetes", zap.Error(err))
return
}
// extract archives referenced by these pkgs
for _, pkg := range pkgList.Items {
if pkg.Spec.Deployment.URL != "" {
archiveID, err = getQueryParamValue(pkg.Spec.Deployment.URL, "id")
if err != nil {
pruner.logger.Error("error extracting value of archiveID from deployment url",
zap.Error(err),
zap.String("url", pkg.Spec.Deployment.URL))
return
}
archivesRefByPkgs = append(archivesRefByPkgs, archiveID)
// extract archives referenced by these pkgs
for _, pkg := range pkgList.Items {
if pkg.Spec.Deployment.URL != "" {
archiveID, err = getQueryParamValue(pkg.Spec.Deployment.URL, "id")
if err != nil {
pruner.logger.Error("error extracting value of archiveID from deployment url",
zap.Error(err),
zap.String("url", pkg.Spec.Deployment.URL))
return
}
if pkg.Spec.Source.URL != "" {
archiveID, err = getQueryParamValue(pkg.Spec.Source.URL, "id")
if err != nil {
pruner.logger.Error("error extracting value of archiveID from source url",
zap.Error(err),
zap.String("url", pkg.Spec.Source.URL))
return
}
archivesRefByPkgs = append(archivesRefByPkgs, archiveID)
archivesRefByPkgs = append(archivesRefByPkgs, archiveID)
}
if pkg.Spec.Source.URL != "" {
archiveID, err = getQueryParamValue(pkg.Spec.Source.URL, "id")
if err != nil {
pruner.logger.Error("error extracting value of archiveID from source url",
zap.Error(err),
zap.String("url", pkg.Spec.Source.URL))
return
}
archivesRefByPkgs = append(archivesRefByPkgs, archiveID)
}
}
+17
View File
@@ -113,6 +113,23 @@ func (nsr *NamespaceResolver) AddNamespace(ns string) bool {
return true
}
// RemoveNamespace removes a namespace from FissionResourceNS.
// Returns true if the namespace was present and removed, false if it was not found.
// Thread-safe. Used when a namespace loses the fission.io/managed=true label so that
// Snapshot() and idleObjectReaper loops no longer iterate over deleted namespaces.
func (nsr *NamespaceResolver) RemoveNamespace(ns string) bool {
nsr.mu.Lock()
defer nsr.mu.Unlock()
if _, exists := nsr.FissionResourceNS[ns]; !exists {
return false
}
delete(nsr.FissionResourceNS, ns)
if nsr.Logger != nil {
nsr.Logger.Info("dynamically removed namespace from resolver", zap.String("namespace", ns))
}
return true
}
// Snapshot returns a stable copy of the currently registered resource namespaces.
// The returned slice is detached from the internal mutable map and safe to iterate.
func (nsr *NamespaceResolver) Snapshot() []string {
+119 -16
View File
@@ -74,6 +74,12 @@ type NamespaceManager interface {
MarkPartActive(namespace string, part string) (NamespaceRecord, bool)
MarkPartFailed(namespace string, part string, err error) (NamespaceRecord, bool)
Remove(name string) bool
// RunReconciler periodically retries namespaces stuck in NamespacePhaseFailed (every 30 s)
// and performs a health-check on Active namespaces (every 60 s) by calling DispatchResync,
// which re-ensures SA/RoleBindings and executor-type registration are intact.
// Must be started as a goroutine; exits when ctx is cancelled.
// logger is used to report retry attempts and outcomes; pass zap.NewNop() to silence.
RunReconciler(ctx context.Context, logger *zap.Logger)
}
type inMemoryNamespaceManager struct {
@@ -142,6 +148,12 @@ func RunManagedNamespaceWatcher(ctx context.Context, logger *zap.Logger, kubeCli
logger = namespaceManagerLogger(logger)
manager, handlers, err := PrepareManagedNamespaceWatcher(ctx, logger, config)
StartManagedNamespaceWatcher(ctx, logger, config.Component, kubeClient, mgr, handlers)
// Start reconciler: retries namespaces stuck in NamespacePhaseFailed every 30s.
mgr.Add(ctx, func(ctx context.Context) {
logger.Info(config.Component + ": namespace reconciler started")
manager.RunReconciler(ctx, logger)
logger.Info(config.Component + ": namespace reconciler stopped")
})
LogNamespaceManagerSummary(logger, config.Component+": started namespace watcher", manager.Summary())
return manager, err
}
@@ -236,6 +248,11 @@ func HandleWatcherNamespaceRemoval(ctx context.Context, logger *zap.Logger, comp
if !ok {
return
}
// Always remove from global resolver so Snapshot() and idleObjectReaper
// stop iterating this namespace. This is safe: if the same name is re-added
// later, AddNamespace will return true and all subscribers will re-register.
DefaultNSResolver().RemoveNamespace(record.Name)
if strategy == NamespaceRemovalStrategyDispatchRemove {
if _, _, err := manager.DispatchRemove(ctx, record.Name); err != nil {
logger.Error(component+": DispatchRemove failed", zap.String("namespace", record.Name), zap.Error(err))
@@ -509,28 +526,44 @@ func (m *inMemoryNamespaceManager) DispatchResync(ctx context.Context, namespace
}
func (m *inMemoryNamespaceManager) dispatch(ctx context.Context, namespace string, handler func(NamespaceSubscriber, NamespaceRecord) error) (NamespaceRecord, bool, error) {
record, ok := m.Get(namespace)
_, ok := m.Get(namespace)
if !ok {
return NamespaceRecord{}, false, nil
}
var firstErr error
for _, subscriber := range m.snapshotSubscriberObjects() {
_, _ = m.MarkPartRegistering(namespace, subscriber.Name())
currentRecord, _ := m.Get(namespace)
err := handler(subscriber, currentRecord)
if err != nil {
_, _ = m.MarkPartFailed(namespace, subscriber.Name(), err)
if firstErr == nil {
firstErr = err
}
continue
}
_, _ = m.MarkPartActive(namespace, subscriber.Name())
subscribers := m.snapshotSubscriberObjects()
for _, sub := range subscribers {
_, _ = m.MarkPartRegistering(namespace, sub.Name())
}
record, _ = m.Get(namespace)
return record, true, firstErr
// Run all subscriber handlers in parallel — each handler makes independent k8s API calls.
// MarkPart* methods are internally mutex-protected and safe for concurrent calls.
var (
wg sync.WaitGroup
mu sync.Mutex
errs error
)
for _, sub := range subscribers {
sub := sub
wg.Add(1)
go func() {
defer wg.Done()
currentRecord, _ := m.Get(namespace)
err := handler(sub, currentRecord)
if err != nil {
_, _ = m.MarkPartFailed(namespace, sub.Name(), err)
mu.Lock()
errs = errors.Join(errs, err)
mu.Unlock()
return
}
_, _ = m.MarkPartActive(namespace, sub.Name())
}()
}
wg.Wait()
record, _ := m.Get(namespace)
return record, true, errs
}
func (m *inMemoryNamespaceManager) MarkPartState(namespace string, part string, state NamespacePartState) (NamespaceRecord, bool) {
@@ -581,6 +614,76 @@ func (m *inMemoryNamespaceManager) Remove(name string) bool {
return true
}
// RunReconciler periodically:
// - retries namespaces stuck in NamespacePhaseFailed via DispatchResync (every 30 s)
// - health-checks Active namespaces by calling DispatchResync (every 60 s); since
// registerNamespace is idempotent, this is a no-op when SA/RoleBindings are intact
// and silently restores them if they were deleted.
//
// logger receives one log line per retry attempt and per outcome.
// Exits when ctx is cancelled.
func (m *inMemoryNamespaceManager) RunReconciler(ctx context.Context, logger *zap.Logger) {
if logger == nil {
logger = zap.NewNop()
}
failedTicker := time.NewTicker(30 * time.Second)
defer failedTicker.Stop()
activeTicker := time.NewTicker(60 * time.Second)
defer activeTicker.Stop()
for {
select {
case <-ctx.Done():
return
case <-failedTicker.C:
m.mu.RLock()
var failedNS []string
for ns, rec := range m.records {
if rec.Phase == NamespacePhaseFailed {
failedNS = append(failedNS, ns)
}
}
m.mu.RUnlock()
if len(failedNS) == 0 {
continue
}
logger.Info("namespace reconciler: retrying failed namespaces",
zap.Int("count", len(failedNS)),
zap.Strings("namespaces", failedNS),
)
for _, ns := range failedNS {
logger.Info("namespace reconciler: dispatching resync", zap.String("namespace", ns))
_, _, err := m.DispatchResync(ctx, ns)
if err != nil {
logger.Error("namespace reconciler: resync still failing, will retry",
zap.String("namespace", ns),
zap.Error(err),
)
} else {
logger.Info("namespace reconciler: resync succeeded", zap.String("namespace", ns))
}
}
case <-activeTicker.C:
m.mu.RLock()
var activeNS []string
for ns, rec := range m.records {
if rec.Phase == NamespacePhaseActive {
activeNS = append(activeNS, ns)
}
}
m.mu.RUnlock()
for _, ns := range activeNS {
_, _, err := m.DispatchResync(ctx, ns)
if err != nil {
logger.Warn("namespace reconciler: active NS health-check failed, marking failed for retry",
zap.String("namespace", ns),
zap.Error(err),
)
}
}
}
}
}
func deriveNamespacePhase(record NamespaceRecord) NamespacePhase {
if len(record.RegisteredParts) == 0 {
return record.Phase
+71
View File
@@ -10,6 +10,7 @@ import (
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
k8sfake "k8s.io/client-go/kubernetes/fake"
k8sCache "k8s.io/client-go/tools/cache"
@@ -818,6 +819,76 @@ func TestNamespaceManagerDispatchRemoveFailure(t *testing.T) {
}
}
// TestStartManagedNamespaceWatcherIntegration проверяет полный маршрут:
// fake k8s client → информер → AddFunc → subscriber.OnNamespaceAdd.
// Это интеграционный тест без real cluster — использует k8sfake.
func TestStartManagedNamespaceWatcherIntegration(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
fakeClient := k8sfake.NewSimpleClientset()
mgr := managerPkg.New()
router := &testNamespaceSubscriber{name: "router"}
// Запускаем watcher — не Bootstrap, только informer.
// config.Namespaces пусто, чтобы не было pre-loaded namespace-ов.
manager, err := RunManagedNamespaceWatcher(ctx, zap.NewNop(), fakeClient, mgr, ManagedNamespaceWatcherConfig{
Component: "router.NSWatcher",
Namespaces: nil,
RemovalStrategy: NamespaceRemovalStrategyTrackOnly,
Subscriber: router,
})
if err != nil {
t.Fatalf("RunManagedNamespaceWatcher failed: %v", err)
}
// Создаём namespace с managed label в fake client.
ns := &corev1.Namespace{}
ns.Name = "tenant-integration-a"
ns.Labels = map[string]string{ManagedNamespaceLabelKey: ManagedNamespaceLabelValue}
if _, err := fakeClient.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}); err != nil {
t.Fatalf("failed to create test namespace in fake client: %v", err)
}
// Ждём, пока informer обработает event и subscriber получит вызов.
// Timeout намеренно короткий (3 секунды) — fake client синхронный.
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if router.addCalls >= 1 {
break
}
time.Sleep(10 * time.Millisecond)
}
if router.addCalls == 0 {
t.Fatalf("expected subscriber.OnNamespaceAdd to be called after namespace creation, got 0 calls")
}
if router.addCalls != 1 {
t.Fatalf("expected exactly 1 add call (namespace is new), got %d", router.addCalls)
}
// Namespace должен быть в manager snapshot.
snapshot := manager.Snapshot()
found := false
for _, name := range snapshot {
if name == ns.Name {
found = true
break
}
}
if !found {
t.Fatalf("expected %s in manager snapshot after informer event, got %v", ns.Name, snapshot)
}
record, ok := manager.Get(ns.Name)
if !ok {
t.Fatalf("expected record for %s in manager", ns.Name)
}
if record.Phase != NamespacePhaseActive {
t.Fatalf("expected active phase after informer add, got %s", record.Phase)
}
}
func TestNamespaceSubscriberFuncs(t *testing.T) {
addCalls := 0
removeCalls := 0
+11 -7
View File
@@ -121,7 +121,8 @@ func (sa *ServiceAccount) runSACheck(ctx context.Context) {
for _, baseNS := range sa.nsResolver.Snapshot() {
for _, permission := range sa.permissions {
targetNS := sa.resolveSANamespace(baseNS, permission.saName)
setupSAAndRoleBindings(ctx, sa.kubernetesClient, sa.logger, targetNS, permission)
// Errors are already logged inside setupSAAndRoleBindings; periodic loop ignores them.
_ = setupSAAndRoleBindings(ctx, sa.kubernetesClient, sa.logger, targetNS, permission)
}
}
}
@@ -134,14 +135,14 @@ func (sa *ServiceAccount) resolveSANamespace(baseNS, saName string) string {
return sa.nsResolver.GetFunctionNS(baseNS)
}
func setupSAAndRoleBindings(ctx context.Context, client kubernetes.Interface, logger *zap.Logger, namespace string, ps *ServiceAccountPermissions) {
func setupSAAndRoleBindings(ctx context.Context, client kubernetes.Interface, logger *zap.Logger, namespace string, ps *ServiceAccountPermissions) error {
SAObj, err := createGetSA(ctx, client, ps.saName, namespace)
if err != nil {
logger.Error("error while creating or getting service account",
zap.String("sa_name", ps.saName),
zap.String("namespace", namespace),
zap.Error(err))
return
return err
}
var rules []rbac.PolicyRule
@@ -177,14 +178,15 @@ func setupSAAndRoleBindings(ctx context.Context, client kubernetes.Interface, lo
role, err := setupRoles(ctx, client, logger, SAObj, rules, suffix)
if err != nil {
logger.Error("error while creating roles", zap.Error(err))
return
return err
}
_, err = setupRoleBinding(ctx, client, logger, SAObj, role, suffix)
if err != nil {
logger.Error("error while creating role bindings", zap.Error(err))
return
return err
}
}
return nil
}
func setupRoles(ctx context.Context, client kubernetes.Interface, logger *zap.Logger, sa *v1.ServiceAccount, rules []rbac.PolicyRule, suffix string) (*rbac.Role, error) {
@@ -314,8 +316,10 @@ func getSAInterval() time.Duration {
}
// EnsureNamespaceSA creates the fission-fetcher ServiceAccount and its Role/RoleBinding
// in the given namespace. Returns an error if SA or RoleBinding creation fails so callers
// can propagate it to the namespace lifecycle manager and trigger a reconcile retry.
// in the given namespace if they do not already exist. Safe to call repeatedly.
// Used by the multi-tenant NS watcher to provision per-namespace SA on NS registration.
func EnsureNamespaceSA(ctx context.Context, client kubernetes.Interface, logger *zap.Logger, ns string) {
setupSAAndRoleBindings(ctx, client, logger, ns, fetcherCheck)
func EnsureNamespaceSA(ctx context.Context, client kubernetes.Interface, logger *zap.Logger, ns string) error {
return setupSAAndRoleBindings(ctx, client, logger, ns, fetcherCheck)
}