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
Naeel 63ce6ea135 layer1: close namespace manager step1 2026-04-26 16:27:53 +03:00
Naeel 7b6ff84188 layer1: harden namespace watcher logging path 2026-04-26 16:27:20 +03:00
Naeel 55d0b5a9e7 layer1: log namespace watcher startup summary 2026-04-26 16:24:29 +03:00
Naeel 813617ffd1 layer1: log watcher namespace transitions 2026-04-26 11:52:06 +03:00
Naeel 7d7fe561a8 layer1: log namespace summary active flag 2026-04-26 11:51:03 +03:00
Naeel fed69da335 layer1: add namespace summary active helper 2026-04-26 11:46:47 +03:00
Naeel 9f0e911b9d layer1: stabilize namespace summary contract 2026-04-26 11:46:15 +03:00
Naeel f4a3bffc6b layer1: test namespace manager summary logging 2026-04-26 11:45:13 +03:00
Naeel 90924cdec7 layer1: checkpoint namespace manager runtime series 2026-04-26 11:44:36 +03:00
Naeel 6e037a506d layer1: add default watcher config helper 2026-04-26 11:03:39 +03:00
Naeel d24605a8b8 layer1: fix managed watcher config wiring 2026-04-26 11:02:54 +03:00
Naeel d2ff55f9e0 layer1: add managed watcher config 2026-04-26 11:02:09 +03:00
Naeel b9236698f3 layer1: run managed namespace watchers 2026-04-26 11:01:05 +03:00
Naeel 073f2c1504 layer1: add live namespace counts 2026-04-26 10:59:39 +03:00
Naeel b93e720e12 layer1: add namespace source counts 2026-04-26 10:59:01 +03:00
Naeel f16aa030db layer1: log namespace manager summary 2026-04-26 10:57:29 +03:00
Naeel 5c481b7293 layer1: add namespace manager summary 2026-04-26 10:55:56 +03:00
Naeel 67db8d71f1 layer1: fix watcher preparation imports 2026-04-26 10:54:51 +03:00
Naeel 2bbed95c2a layer1: prepare managed namespace watchers 2026-04-26 10:54:24 +03:00
Naeel a1517ba4b2 layer1: share managed namespace watcher startup 2026-04-26 10:51:31 +03:00
Naeel 49be1db3a0 layer1: share namespace watcher event handlers 2026-04-26 10:50:28 +03:00
Naeel c3b161da83 layer1: share update removal policy 2026-04-26 10:49:26 +03:00
Naeel 0755319fac layer1: formalize namespace removal strategy 2026-04-26 10:48:42 +03:00
Naeel 340b9cae84 layer1: centralize namespace watcher handlers 2026-04-26 10:47:12 +03:00
Naeel 4cd4bc9507 layer1: share namespace watcher lifecycle helpers 2026-04-26 10:45:03 +03:00
Naeel 0e08664ef6 layer1: share watcher namespace manager bootstrap 2026-04-26 10:41:14 +03:00
Naeel d7497dcd34 layer1: remove old namespace watcher helpers 2026-04-26 10:40:36 +03:00
Naeel 447133d5b2 layer1: track namespace removals in manager 2026-04-26 10:39:59 +03:00
Naeel b6f3640bbe layer1: add namespace tombstone helper step 34 2026-04-26 10:39:11 +03:00
Naeel d09bee3431 layer1: add namespace remove dispatch step 33 2026-04-26 10:36:48 +03:00
Naeel 488157963a layer1: bootstrap executor namespace manager 2026-04-26 10:35:50 +03:00
Naeel 804bc533db layer1: bootstrap router namespace manager 2026-04-26 10:35:29 +03:00
Naeel 65837610a1 layer1: bootstrap builder namespace manager 2026-04-26 10:35:09 +03:00
Naeel dd7470922c layer1: hook executor watcher to namespace manager 2026-04-26 10:34:34 +03:00
Naeel 0135a93a30 layer1: hook router watcher to namespace manager 2026-04-26 10:34:04 +03:00
Naeel b12a8e5e75 layer1: hook builder watcher to namespace manager 2026-04-26 10:33:36 +03:00
Naeel ad0f83fd4b layer1: add namespace bootstrap dispatch step 26 2026-04-26 10:32:43 +03:00
Naeel 6f77fa5a9a layer1: add executor namespace subscriber step 25 2026-04-26 10:31:54 +03:00
Naeel 331f531962 layer1: factor executor namespace registration step 24 2026-04-26 10:30:59 +03:00
Naeel 346399d35f layer1: align router watcher flow step 23 2026-04-26 10:29:43 +03:00
Naeel 33a08f00b3 layer1: fix router namespace subscriber syntax 2026-04-26 10:29:19 +03:00
Naeel 2971029c42 layer1: add router namespace subscriber step 22 2026-04-26 10:28:57 +03:00
Naeel 3159fba65b layer1: align builder watcher flow step 21 2026-04-26 10:27:09 +03:00
Naeel b200b8bb5b layer1: fix builder namespace subscriber syntax 2026-04-26 10:26:40 +03:00
Naeel 126f7cc51c layer1: add builder namespace subscriber step 20 2026-04-26 10:26:13 +03:00
Naeel bcf34d6b1a layer1: add namespace subscriber adapter step 19 2026-04-26 10:24:01 +03:00
Naeel 022960ade7 layer1: add namespace event bridge step 18 2026-04-26 10:23:21 +03:00
Naeel b1e2e6462d layer1: add namespace dispatch step 17 2026-04-26 10:22:35 +03:00
Naeel ae8275d0a7 layer1: add namespace lifecycle subscribers step 16 2026-04-26 10:21:26 +03:00
Naeel 2857398e11 layer1: checkpoint pending utils changes 2026-04-26 10:20:56 +03:00
Naeel 834c0de941 layer1: add namespace event helpers step 15 2026-04-26 10:05:51 +03:00
Naeel db4499d8c7 layer1: add namespace part helpers step 14 2026-04-26 10:05:12 +03:00
Naeel b3f99b2b6c layer1: centralize managed namespace labels step 13 2026-04-26 10:04:18 +03:00
Naeel 5e5058ba0e layer1: add namespace manager bridge step 12 2026-04-26 10:03:09 +03:00
Naeel 42acce308f layer1: add namespace bootstrap step 11 2026-04-26 10:02:37 +03:00
Naeel 66a3dc2a3c layer1: derive namespace phases step 10 2026-04-26 10:02:03 +03:00
Naeel 910f65b6d4 layer1: add namespace manager subscribers step 9 2026-04-26 10:01:21 +03:00
Naeel 114d5b99af layer1: add namespace manager skeleton step 8 2026-04-26 10:00:45 +03:00
Naeel ac2638f17d layer1: add namespace manager model step 7 2026-04-26 09:54:23 +03:00
Naeel 97b13a13c2 doc: add namespace manager target design 2026-04-26 09:53:06 +03:00
Naeel 1f53bc1fb7 doc: add detailed namespace rewrite logic 2026-04-26 09:50:06 +03:00
Naeel 87477d4529 layer1: guard router informer maps step 6 2026-04-26 09:37:09 +03:00
Naeel 94f26b69ee layer1: fix newdeploy namespace parity step 5 2026-04-26 09:35:39 +03:00
Naeel 56a499a59f layer1: fix buildermgr namespace dedup step 4 2026-04-26 09:34:46 +03:00
Naeel 6102b277c8 layer1: migrate runtime loops to snapshots step 3 2026-04-26 09:33:36 +03:00
Naeel 9ce9829f3b layer1: fix sa namespace routing step 2 2026-04-26 09:32:08 +03:00
Naeel c987fa07e8 layer1: add namespace snapshot api step 1 2026-04-26 09:30:51 +03:00
Naeel 27a280bc03 doc: record debugging comparison notes 2026-04-26 09:11:18 +03:00
Naeel e2dff8db09 doc: add detailed layer1 multi-tenant fix report 2026-04-26 09:06:44 +03:00
Naeel 7faaa9dc1f rbac: allow router namespace watch in multi-tenant mode 2026-04-26 09:03:15 +03:00
Naeel f617913ad9 rbac: allow full fetcher role provisioning in dynamic namespaces 2026-04-26 08:59:02 +03:00
Naeel 8ccc9fb342 rbac: add fission-executor-sa-provisioner for SA/Role/RoleBinding creation in user NS
Fixes EnsureNamespaceSA getting 403 Forbidden when provisioning fission-fetcher
SA in dynamically registered namespaces. Adds ClusterRole + ClusterRoleBinding
with create/update/patch for serviceaccounts, roles, rolebindings.

Also adds doc/progress.md and doc/thinking/2026-04-26-rbac-fix.md.
2026-04-26 07:46:33 +03:00
Naeel 161de70576 multi-tenant: EnsureNamespaceSA + ns_watcher SA provisioning (v8) 2026-04-26 07:41:46 +03:00
110 changed files with 9688 additions and 76 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
+3
View File
@@ -0,0 +1,3 @@
FROM gcr.io/distroless/static-debian12:nonroot
COPY fission-bundle /
ENTRYPOINT ["/fission-bundle"]
BIN
View File
Binary file not shown.
+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.
##
+3
View File
@@ -0,0 +1,3 @@
FROM cgr.dev/chainguard/static:latest@sha256:a301031ffd4ed67f35ca7fa6cf3dad9937b5fa47d7493955a18d9b4ca5412d1a
COPY fission-bundle /
ENTRYPOINT ["/fission-bundle"]
Binary file not shown.
+122
View File
@@ -0,0 +1,122 @@
# deploy/multitenant/rbac.yaml
#
# RBAC required for the Fission multi-tenant NSWatcher components.
#
# Both fission-executor and fission-router must be allowed to list and watch
# Namespaces at the cluster scope so that their NSWatchers can detect newly-
# labeled Namespaces.
#
# The executor also needs additional write permissions to provision the
# fission-fetcher ServiceAccount/Role/RoleBinding in new namespaces.
# Apply once per cluster after installing Fission:
#
# kubectl apply -f deploy/multitenant/rbac.yaml
#
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: fission-executor-ns-watcher
labels:
app.kubernetes.io/name: fission
app.kubernetes.io/component: executor
app.kubernetes.io/part-of: fission-multitenant
rules:
- apiGroups: [""]
resources: ["namespaces"]
verbs: ["list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: fission-executor-ns-watcher
labels:
app.kubernetes.io/name: fission
app.kubernetes.io/component: executor
app.kubernetes.io/part-of: fission-multitenant
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: fission-executor-ns-watcher
subjects:
- kind: ServiceAccount
name: fission-executor
namespace: fission
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: fission-router-ns-watcher
labels:
app.kubernetes.io/name: fission
app.kubernetes.io/component: router
app.kubernetes.io/part-of: fission-multitenant
rules:
- apiGroups: [""]
resources: ["namespaces"]
verbs: ["list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: fission-router-ns-watcher
labels:
app.kubernetes.io/name: fission
app.kubernetes.io/component: router
app.kubernetes.io/part-of: fission-multitenant
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: fission-router-ns-watcher
subjects:
- kind: ServiceAccount
name: fission-router
namespace: fission
---
# ClusterRole: allows fission-executor to create/update fission-fetcher SA,
# Role and RoleBinding in any user namespace managed by NSWatcher.
#
# It also needs two less-obvious permissions:
# 1. localsubjectaccessreviews.create — setupSAAndRoleBindings checks whether
# the target SA already has each permission before creating missing rules.
# 2. events.create — Kubernetes forbids creating a Role that grants permissions
# the caller does not currently hold. Since fission-fetcher gets events.create,
# fission-executor must hold it too in order to create that Role.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: fission-executor-sa-provisioner
labels:
app.kubernetes.io/name: fission
app.kubernetes.io/component: executor
app.kubernetes.io/part-of: fission-multitenant
rules:
- apiGroups: [""]
resources: ["serviceaccounts"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
- apiGroups: [""]
resources: ["events"]
verbs: ["create"]
- apiGroups: ["authorization.k8s.io"]
resources: ["localsubjectaccessreviews"]
verbs: ["create"]
- apiGroups: ["rbac.authorization.k8s.io"]
resources: ["roles", "rolebindings"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: fission-executor-sa-provisioner
labels:
app.kubernetes.io/name: fission
app.kubernetes.io/component: executor
app.kubernetes.io/part-of: fission-multitenant
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: fission-executor-sa-provisioner
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 |
+52
View File
@@ -0,0 +1,52 @@
# Fission Multi-Tenant — Progress
## Задача
Добиться 5/5 PASS в `test_layer1.sh`: динамически добавленный NS с меткой `fission.io/managed=true` должен работать без рестарта Fission.
---
## Статус задач
| # | Задача | Статус |
|---|--------|--------|
| 1 | Добавить `EnsureNamespaceSA` в `pkg/utils/serviceaccount.go` | ✅ DONE |
| 2 | Вызов `EnsureNamespaceSA` из `ns_watcher.go` при регистрации NS | ✅ DONE |
| 3 | Сборка образа `naeel/fission-bundle:v1.22.0-multi-ns-8` | ✅ DONE |
| 4 | Деплой образа v8 в кластер (executor/router/buildermgr) | ✅ DONE |
| 5 | Коммит `161de70` "multi-tenant: EnsureNamespaceSA + ns_watcher SA provisioning (v8)" | ✅ DONE |
| 6 | Исправить RBAC: добавить полный набор прав для SA provisioning в `deploy/multitenant/rbac.yaml` | ✅ DONE |
| 7 | Применить RBAC через `kubectl apply`, верифицировать SA/Role/RoleBinding | ✅ DONE |
| 8 | Коммит RBAC fix | 🔄 IN PROGRESS |
| 9 | Запустить `test_layer1.sh`, добиться 5/5 PASS | ⏳ TODO |
---
## Текущий результат теста
`test_layer1.sh` — 4/5:
- Шаг 5 падает: `serviceaccount "fission-fetcher" not found` в NS `l1-test-77773`
## Диагностика (2026-04-26)
- Код `EnsureNamespaceSA` присутствует в `serviceaccount.go`
- `ns_watcher.go` строка 168 вызывает `EnsureNamespaceSA`
- RBAC: `kubectl auth can-i create serviceaccounts --as=...fission-executor -n l1-test-77773`**`no`** ❌
- ClusterRole `fission-executor-multi-ns` не имеет `create` для `serviceaccounts`, и нет rules для `roles`/`rolebindings`
- Вывод: `setupSAAndRoleBindings` вызывается, но получает 403 Forbidden и тихо фейлится → SA не создаётся → pod не стартует
## Решение
Добавить в `deploy/multitenant/rbac.yaml` новый ClusterRole + ClusterRoleBinding с правами:
- `serviceaccounts`: `get/list/watch/create/update/patch`
- `roles`, `rolebindings`: `get/list/watch/create/update/patch`
- `events`: `create`
- `localsubjectaccessreviews.authorization.k8s.io`: `create`
Применить через `kubectl apply`.
**Пересборка образа НЕ нужна** — логика правильная, проблема только в RBAC.
## Последняя верификация
- `kubectl auth can-i create events --as=system:serviceaccount:fission:fission-executor``yes`
- `kubectl auth can-i create localsubjectaccessreviews.authorization.k8s.io --as=system:serviceaccount:fission:fission-executor``yes`
- В новом NS `rbac-verify-83117` автоматически созданы:
- `ServiceAccount/fission-fetcher`
- `Role/fission-fetcher-role-*`
- `RoleBinding/fission-fetcher-rolebinding-*`
+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,248 @@
# 2026-04-26 - Layer1 multi-tenant NSWatcher: полный разбор до 5/5 PASS
## Цель
Довести `test_layer1.sh` до `PASS=5 FAIL=0` для сценария:
1. создаётся новый namespace
2. namespace получает label `fission.io/managed=true`
3. Fission без рестарта подхватывает namespace
4. в namespace создаются `Environment`, `Function`, `HTTPTrigger`
5. функция успешно вызывается через router
Ключевое требование: всё должно происходить без rolling restart Fission-компонентов.
## Исходный симптом
Первый устойчивый симптом был таким:
- `test_layer1.sh` стабильно доходил до `4/5`
- шаг вызова функции падал
- в user namespace наблюдалось:
- `FailedCreate`
- `serviceaccount "fission-fetcher" not found`
Это означало, что poolmgr deployment для environment уже создаётся, но pod не может стартовать без `fission-fetcher` ServiceAccount.
## Что уже было исправлено до RBAC-этапа
Кодовая часть hot-registration была уже внедрена ранее:
- `pkg/utils/serviceaccount.go`
- добавлена `EnsureNamespaceSA(...)`
- `pkg/executor/multitenant/ns_watcher.go`
- при регистрации нового namespace вызывается `EnsureNamespaceSA(...)`
- образ `naeel/fission-bundle:v1.22.0-multi-ns-8` уже был собран и задеплоен
То есть логика в коде уже существовала; сбой был не в отсутствии вызова, а в невозможности выполнить его успешно в кластере.
## Диагностика 1: executor не может создать ServiceAccount/Role/RoleBinding
Была проведена проверка прав service account `fission-executor`.
Подтверждено:
- код `EnsureNamespaceSA` вызывается
- `ns_watcher` регистрирует namespace
- executor не имеет достаточных RBAC-прав для provisioning ресурсов в новом namespace
Первый явный пробел:
- отсутствовали права на:
- `serviceaccounts`
- `roles`
- `rolebindings`
После начального RBAC fix было видно, что `ServiceAccount/fission-fetcher` уже создаётся, но этого оказалось недостаточно.
## Диагностика 2: initial RBAC fix оказался неполным
После расширения прав на `serviceaccounts/roles/rolebindings` тест перестал падать на отсутствии SA, но при детальной диагностике выяснилось, что `EnsureNamespaceSA` всё ещё не может полностью создать `Role` для fetcher.
Ключевой лог executor:
```text
error while creating role for sa fission-fetcher in namespace diag-ns-82702
... is attempting to grant RBAC permissions not currently held:
{APIGroups:[""], Resources:["events"], Verbs:["create"]}
```
И дополнительный лог перед этим:
```text
localsubjectaccessreviews.authorization.k8s.io is forbidden
```
### Что это означает
Функция `setupSAAndRoleBindings()` делает две важные вещи:
1. пытается проверить уже существующие права через `LocalSubjectAccessReview`
2. если прав нет, создаёт `Role` с нужными permission-ами
Следовательно executor должен иметь не только право создавать `Role/RoleBinding`, но и:
- `authorization.k8s.io/localsubjectaccessreviews:create`
- все permission-ы, которые он пытается делегировать через создаваемую `Role`
В нашем случае fetcher получает право:
- `events:create`
По правилам Kubernetes нельзя создать `Role`, выдающую право, которого нет у самого вызывающего субъекта. Поэтому executor должен был сам иметь `events:create`.
### Реальный root cause на этом этапе
`fission-executor` не имел:
- `events.create`
- `localsubjectaccessreviews.create`
Из-за этого:
- `ServiceAccount` создавался
- но `Role` и `RoleBinding` создавались не полностью или не создавались вовсе
- downstream specialization ломалась
## Исправление 1: полный executor RBAC для dynamic SA provisioning
В `deploy/multitenant/rbac.yaml` был добавлен и затем расширен `ClusterRole`:
- `fission-executor-sa-provisioner`
Итоговый набор прав для него:
- core:
- `serviceaccounts`: `get`, `list`, `watch`, `create`, `update`, `patch`
- `events`: `create`
- `authorization.k8s.io`:
- `localsubjectaccessreviews`: `create`
- `rbac.authorization.k8s.io`:
- `roles`: `get`, `list`, `watch`, `create`, `update`, `patch`
- `rolebindings`: `get`, `list`, `watch`, `create`, `update`, `patch`
После применения этого манифеста было подтверждено:
- `kubectl auth can-i create events --as=system:serviceaccount:fission:fission-executor` -> `yes`
- `kubectl auth can-i create localsubjectaccessreviews.authorization.k8s.io --as=system:serviceaccount:fission:fission-executor` -> `yes`
И в новом test namespace автоматически появлялись:
- `ServiceAccount/fission-fetcher`
- `Role/fission-fetcher-role-*`
- `RoleBinding/fission-fetcher-rolebinding-*`
## Изменение симптома после executor-fix
После полного executor RBAC fix шаг 5 перестал падать с `500` timeout от executor.
Новый симптом:
- постоянный `HTTP 404`
- router не видел route/function в новом namespace
Это был важный индикатор того, что executor-path уже работает лучше, а оставшаяся проблема находится в router-path.
## Диагностика 3: router NSWatcher не мог watch/list namespaces
Лог router показал прямую ошибку:
```text
failed to list *v1.Namespace: namespaces is forbidden:
User "system:serviceaccount:fission:fission-router" cannot list resource
"namespaces" at the cluster scope
```
При этом код router уже содержал dynamic namespace watcher:
- `pkg/router/ns_watcher.go`
То есть логика была, но RBAC для `fission-router` отсутствовал.
### Реальный root cause на этом этапе
`fission-router` не имел cluster-scope прав:
- `namespaces:list`
- `namespaces:watch`
Из-за этого:
- router не подхватывал новые labeled namespaces
- `HTTPTriggerSet.AddNamespace(...)` не вызывался
- HTTP trigger не попадал в router runtime map
- вызов функции возвращал `404`
## Исправление 2: router RBAC для NSWatcher
В тот же `deploy/multitenant/rbac.yaml` добавлены:
- `ClusterRole/fission-router-ns-watcher`
- `ClusterRoleBinding/fission-router-ns-watcher`
С правами:
- core `namespaces`: `list`, `watch`
После применения подтверждено:
- `kubectl auth can-i list namespaces --as=system:serviceaccount:fission:fission-router` -> `yes`
- `kubectl auth can-i watch namespaces --as=system:serviceaccount:fission:fission-router` -> `yes`
## Финальная проверка
После обоих RBAC fixes повторный запуск `test_layer1.sh` дал:
```text
ИТОГ: PASS=5 FAIL=0
```
На шаге 5 функция успешно ответила:
```text
HTTP 200 - hello from layer1
```
## Что именно оказалось правдой по итогу
Итоговая проблема состояла из двух последовательных RBAC-дырок:
1. executor не мог полностью provision-ить `fission-fetcher` в динамическом namespace
2. router не мог подхватить новый namespace из-за отсутствия namespace watch/list
То есть код hot-registration в целом был правильный, но runtime contract в Kubernetes RBAC был реализован не полностью.
## Итоговые изменения
### Код и манифесты
- `deploy/multitenant/rbac.yaml`
- executor namespace watch
- executor SA provisioning RBAC
- router namespace watch RBAC
### Документация
- `doc/progress.md`
- `doc/thinking/2026-04-26-rbac-fix.md`
- `doc/thinking/2026-04-26-layer1-pass-detailed.md`
### Коммиты по ходу исправления
- `161de70` - `multi-tenant: EnsureNamespaceSA + ns_watcher SA provisioning (v8)`
- `8ccc9fb` - первый RBAC commit
- `f617913` - полный executor RBAC fix для fetcher role provisioning
- `7faaa9d` - router namespace watch RBAC
## Практический вывод
Для hot namespace onboarding в Fission недостаточно просто добавить informer-ы в коде.
Нужно обеспечить весь runtime contract:
- executor видит namespace
- executor может provision-ить service accounts и RBAC в tenant namespace
- executor может делегировать все требуемые permission-ы
- router видит namespace и подписывается на triggers/functions в нём
Если хотя бы одно из этих звеньев отсутствует, поведение выглядит как "код вроде есть, но dynamic namespace не работает".
@@ -0,0 +1,589 @@
# 2026-04-26 — Layer 1 namespace rewrite: подробная логика правок
## Зачем этот документ
Нужен не просто список коммитов, а объяснение инженерной логики:
- что именно было не так в коде;
- почему исправление выбрано именно таким;
- почему изменения разбиты на маленькие шаги;
- какие инварианты я старался сохранить;
- что уже исправлено, а что еще нет.
Этот документ описывает серию маленьких безопасных шагов в ветке
`rewrite/layer1-namespace-manager-step1`.
Основной принцип серии:
1. Не делать большой взрывной rewrite.
2. Сначала сузить race-surface и разъединить старую статическую модель от новой динамической.
3. Исправлять реальные дефекты отдельно от mechanical refactor.
4. После каждого шага отдельно проверять соответствующий пакет тестами.
---
## Исходная архитектурная проблема
Переделанный Layer 1 жил в гибридном состоянии.
Старая модель Fission:
- список resource namespaces задается один раз на старте;
- компоненты считают этот список immutable;
- informer factories строятся из startup configuration.
Новая multi-tenant модель:
- namespace появляется позже, уже после старта процесса;
- watcher видит label `fission.io/managed=true`;
- компоненты должны подключить новый namespace на лету.
Из-за этого в коде образовался разрыв между двумя мирами:
1. Часть кода уже работает как dynamic system.
2. Часть кода все еще читает глобальную map namespace-ов напрямую, как будто она immutable.
3. В некоторых компонентах startup-path и dynamic-path оказались несимметричными.
4. В некоторых местах общий global dedup конфликтует с локальной логикой конкретного компонента.
Это и есть корневой дефект всей подсистемы: не один конкретный баг, а отсутствие единого namespace lifecycle contract.
---
## Что было решено не делать сразу
Я сознательно не пошел в большой rewrite в один коммит.
Почему:
1. Слишком много точек входа: executor, router, buildermgr, storagesvc, utils.
2. Если переписать все сразу, невозможно будет локализовать регрессию.
3. Уже были реальные functional дефекты в нескольких местах, их удобнее чинить изолированно.
4. Пользователь отдельно попросил идти последовательно и проверять после каждого изменения.
Поэтому выбран bounded rewrite: сначала вычищать старые опасные предположения, затем исправлять функциональные несовпадения, и только потом идти к более крупному NamespaceManager.
---
## Инварианты серии
Во всех шагах я старался держать одинаковые правила.
### 1. Не ломать действующий onboarding contract
Если namespace приходит через label watcher, компоненты должны продолжать подключать его без рестарта. Нельзя было ради рефактора возвращаться к статической модели.
### 2. Не менять лишние контракты одновременно
Если шаг про snapshot API, он не должен заодно переписывать cleanup semantics.
### 3. Сначала механические и безопасные сдвиги, потом functional fixes
Это нужно, чтобы понимать, баг возник из-за новой логики или уже существовал ранее.
### 4. Каждый шаг должен быть проверяем локально
После каждого шага запускались тесты по затронутому пакету, а не абстрактное «кажется, всё нормально».
---
## Step 1 — Snapshot API для namespace resolver
Коммит: `c987fa0`
### Что было не так
`NamespaceResolver` уже имел mutex для записи через `AddNamespace`, но многие потребители читали `FissionResourceNS` напрямую.
Это означало следующее:
1. Запись в map уже динамическая.
2. Чтение в части мест по-прежнему не thread-safe.
3. Код внешне выглядел как безопасный, потому что mutex в структуре есть, но контракт чтения не был централизован.
То есть защита существовала только наполовину.
### Что я сделал
В `pkg/utils/namespace.go` добавлены:
- `Snapshot()`
- `SnapshotWithOptions()`
Их логика:
1. Под read lock взять текущее состояние.
2. Скопировать его в detached slice.
3. Отсортировать, чтобы получить стабильный детерминированный порядок.
Почему именно slice snapshot, а не снова map:
1. Читателям в основном нужен именно проход по namespace-ам.
2. Slice удобнее для безопасной итерации.
3. Сортировка убирает дрожание порядка и делает поведение более предсказуемым в тестах и логике startup factory generation.
### Почему это был правильный первый шаг
Этот шаг почти не меняет бизнес-логику. Он не трогает watchers, RBAC, cleanup, lifecycle events. Он вводит базовый безопасный API, на который потом можно переводить потребителей.
### Что было переведено сразу
Чтобы snapshot API не оставался мертвым кодом, на него были переведены:
- `pkg/utils/informer.go`
- startup factory creation в `pkg/executor/executor.go`
Логика этого выбора:
1. Это общие helper path.
2. Они касаются большого числа компонентов.
3. Но при этом change поверхностный: вместо прямой итерации по map берется snapshot.
### Отдельный мелкий дефект, найденный на шаге 1
Новые тесты создали локальный `NamespaceResolver` без logger. Выяснилось, что часть методов предполагает ненулевой logger. Это нехорошо само по себе: utility object не должен падать только потому, что его используют вне global singleton.
Поэтому были добавлены nil checks вокруг debug/info логов в resolver.
### Проверка шага
Проверялось:
- `go test ./pkg/utils/...`
- `go test ./pkg/executor/...`
Смысл проверки:
1. Убедиться, что snapshot API корректен как utility layer.
2. Убедиться, что startup path executor не поменял поведение.
---
## Step 2 — Исправление namespace routing в serviceaccount checker
Коммит: `9ce9829`
### Что было не так
В `pkg/utils/serviceaccount.go` был более тонкий дефект, чем просто прямое чтение map.
В `runSACheck()` одна и та же переменная `ns` переиспользовалась внутри цикла по permission groups.
Смысл проблемы:
1. Есть исходный base namespace.
2. Для fetcher нужен путь через `GetFunctionNS(baseNS)`.
3. Для builder нужен путь через `GetBuilderNS(baseNS)`.
4. Но код мутировал саму переменную `ns` по мере обхода permission sets.
Это опасно, потому что builder resolution начинает зависеть от предыдущего шага цикла, а не от исходного namespace.
Если `FunctionNamespace` и `BuilderNamespace` различаются, route builder SA может поехать.
### Что я сделал
Изменение было разбито на две части:
1. Итерироваться не по `FissionResourceNS` напрямую, а по `Snapshot()`.
2. Явно вычислять `targetNS` из `baseNS` через отдельный метод `resolveSANamespace(baseNS, saName)`.
Почему выделен отдельный метод:
1. Логика namespace routing становится читаемой как отдельный контракт.
2. Её можно тестировать отдельно.
3. В коде исчезает скрытая мутация переменной цикла.
### Почему я не переписывал весь serviceaccount.go сразу
В файле еще остаются спорные места:
- глобальные `fetcherCheck` / `builderCheck`;
- мутация `permission.exists`;
- runtime provisioning через `LocalSubjectAccessReview`.
Но если решать всё сразу, шаг становится слишком широким. На этом этапе была цель исправить именно namespace routing bug и убрать прямую итерацию по общей map.
### Какой тест был добавлен
Добавлен unit test на `resolveSANamespace()`:
- fetcher на default namespace должен идти в function namespace;
- builder на default namespace должен идти в builder namespace;
- tenant namespace должен сохраняться как tenant namespace.
Тест важен не из-за синтаксиса, а потому что он фиксирует смысловую развязку между двумя namespace path.
### Проверка шага
Проверялось:
- `go test ./pkg/utils/...`
- `go test ./pkg/executor/...`
---
## Step 3 — Перевод runtime loops на snapshot API
Коммит: `6102b27`
### Что было не так
Даже после появления snapshot API ещё оставались runtime loops, которые напрямую читали общую map namespace-ов в горячих путях:
- adopt existing resources;
- idle object reaper;
- orphan archive pruning.
Это плохо не только из-за race. Это также концептуально закрепляет старую модель «список namespace-ов — это просто глобальная map, в которую можно смотреть отовсюду».
### Что я сделал
Перевёл на `Snapshot()` следующие места:
- `pkg/executor/executortype/container/containermgr.go`
- `pkg/executor/executortype/newdeploy/newdeploymgr.go`
- `pkg/executor/executortype/poolmgr/gpm.go`
- `pkg/storagesvc/archivePruner.go`
### Почему именно эти места были хорошим кандидатом
Потому что это mechanical refactor:
1. Логика списков не меняется.
2. Namespace source меняется с raw map на stable snapshot.
3. Поведение должно оставаться тем же, кроме устранения unsafe read.
### Что это дало
1. Уменьшило площадь прямого доступа к глобальному mutable состоянию.
2. Подготовило код к следующему этапу, когда namespace registry станет ещё более централизованным.
3. Сделало background loops более предсказуемыми при одновременном dynamic onboarding.
### Проверка шага
Проверялось:
- `go test ./pkg/executor/... ./pkg/storagesvc/...`
---
## Step 4 — Исправление buildermgr dedup bug
Коммит: `56a499a`
### Это уже не mechanical refactor, а реальный functional fix
### Что было не так
`buildermgr.StartNSWatcher()` при появлении нового namespace делал:
1. `envw.AddNamespace()`
2. `pkgw.AddNamespace()`
Но оба watcher-а использовали один и тот же глобальный dedup через `nsResolver.AddNamespace()`.
Фактический эффект:
1. Первый вызов успешно добавляет namespace в global resolver.
2. Второй вызов видит, что namespace уже «есть».
3. И просто выходит.
То есть в buildermgr динамический namespace мог получить только часть подписок.
Это уже не theoretical risk, а реальный дефект логики.
### Почему проблема архитектурная
Здесь смешались два уровня ответственности:
1. Global registry должен знать, что namespace существует.
2. Конкретный компонент должен знать, подписался ли он уже на этот namespace.
Это разные виды dedup.
Один глобальный dedup не может корректно заменить локальный dedup для двух разных subcomponents.
### Что я сделал
Логику развёл по уровням:
1. В `pkg/buildermgr/ns_watcher.go` global resolver обновляется один раз.
2. `environmentWatcher` dedup делает по своей map `envWatchInformer`.
3. `packageWatcher` dedup делает по своей map `pkgInformer`.
### Почему это правильнее
Теперь структура похожа на executor path:
1. Глобальный реестр говорит: namespace известен системе.
2. Каждый компонент сам решает: свои informers он уже поднял или нет.
Именно так должен выглядеть multi-component dynamic onboarding.
### Что я сознательно не делал
Не добавлял remove/cleanup и не переделывал buildermgr lifecycle целиком. На шаге требовалось только убрать ошибку дедупликации.
### Проверка шага
Проверялось:
- `go test ./pkg/buildermgr/...`
Тестов в пакете немного, но для этого шага важно было хотя бы подтвердить, что wiring собирается и не поломан compile-time.
---
## Step 5 — Исправление parity gap в newdeploy
Коммит: `94f26b6`
### Что было не так
`MakeNewDeploy()` на старте процесса регистрировал оба типа handler-ов:
- `FunctionEventHandlers()`
- `EnvEventHandlers()`
Но `AddNamespace()` для динамически появившегося namespace регистрировал только `FunctionEventHandlers()`.
Это значит, что два namespace-а с одинаковым содержимым вели себя по-разному только из-за времени появления:
1. startup namespace обслуживается полным code path;
2. dynamic namespace обслуживается урезанным code path.
Это очень плохое свойство для Layer 1, потому что поведение перестаёт зависеть только от данных и начинает зависеть от истории запуска процесса.
### Что я сделал
В `newdeploy.AddNamespace()` добавил регистрацию `EnvEventHandlers()` рядом с `FunctionEventHandlers()`.
### Почему fix именно такой
Потому что это минимальное исправление семантической несимметрии.
Я не придумывал новую абстракцию, а привёл dynamic path к уже существующему startup contract.
### Инженерный смысл шага
Это важный принцип всей серии: если startup-path и late onboarding-path делают похожую работу, они должны проходить через один и тот же контракт, а не через два слегка разных набора side effects.
### Проверка шага
Проверялось:
- `go test ./pkg/executor/executortype/newdeploy`
---
## Step 6 — Защита router informer maps от гонок
Коммит: `87477d4`
### Что было не так
В router динамический namespace добавляет новые informer-ы в две map:
- `triggerInformer`
- `funcInformer`
Параллельно `updateRouter()` итерируется по тем же map, собирая триггеры и функции для rebuild router-а.
Плюс `functionReferenceResolver` получает `funcInformer` и тоже читает его напрямую.
Это создаёт классическую проблему:
1. одна goroutine пишет в map;
2. другая одновременно по ней итерируется;
3. третья читает её через resolver.
Результат может быть от паники `concurrent map iteration and map write` до тихого чтения неполного состояния.
### Почему шаг стал чуть шире
Простой mutex только вокруг `HTTPTriggerSet.AddNamespace()` не решал бы проблему полностью, потому что `functionReferenceResolver` держал свою ссылку на ту же mutable структуру.
Поэтому понадобилось сделать две вещи одновременно:
1. Защитить maps в `HTTPTriggerSet` через `RWMutex` и snapshot helpers.
2. Дать `functionReferenceResolver` собственный thread-safe путь доступа к informer registry.
### Что я сделал
В `HTTPTriggerSet`:
- добавлен `RWMutex`;
- добавлены `snapshotTriggerInformers()`;
- добавлены `snapshotFuncInformers()`;
- `updateRouter()` и setup handlers теперь работают по snapshot-спискам.
В `functionReferenceResolver`:
- добавлен `RWMutex`;
- чтение informer-а по namespace теперь под read lock;
- добавлен `addInformer()` для безопасного добавления нового namespace.
В `router.AddNamespace()`:
- запись в `triggerInformer` и `funcInformer` идёт под lock;
- resolver получает новый informer через собственный безопасный метод.
### Почему именно snapshot-helpers, а не держать lock во время всей итерации
Потому что rebuild router-а и чтение store-ов могут быть относительно дорогими. Держать глобальный lock на всё это время было бы лишним. Нам нужен был не coarse lock на длинный процесс, а короткий lock на получение стабильного снимка ссылок на informer-ы.
То есть стратегия такая:
1. Быстро снять snapshot ссылок.
2. Отпустить lock.
3. Работать со snapshot уже без блокировки записи.
Это лучше и по безопасности, и по latency.
### Проверка шага
Проверялось:
- `go test ./pkg/router/...`
---
## Почему шаги документировались отдельно
Я сохранял отдельный thinking-файл на каждый шаг не ради бюрократии, а ради трассируемости.
Когда изменения маленькие, отдельные документы позволяют понять:
1. какой дефект исправлял именно этот коммит;
2. что было осознанно оставлено за рамками;
3. какой тест подтверждал именно этот шаг;
4. где functional fix, а где только mechanical safety refactor.
Именно это позволяет потом анализировать regressions не по памяти, а по истории.
---
## Что осталось нерешённым после step 6
Несмотря на шесть шагов, это ещё не финальный NamespaceManager rewrite.
Остаются важные вопросы.
### 1. Нет remove/cleanup semantics
Система умеет add, но почти не умеет delete/relabel cleanup.
Что это значит practically:
- informer-ы и локальные registry entries живут вечно;
- once onboarded, always onboarded;
- короткоживущие tenant namespace-ы будут оставлять мусор.
### 2. `serviceaccount.go` всё ещё не идеален
Текущий `serviceaccount.go` уже лучше, чем до step 2, но файл всё ещё сложный:
- глобальные `fetcherCheck` / `builderCheck` живут как process-wide mutable objects;
- `permission.exists` мутируется в runtime;
- provisioning и permission-check тесно сцеплены.
Это отдельный кандидат на следующий bounded refactor, но уже не маленький mechanical шаг.
### 3. Глобальный resolver всё ещё остаётся transitional abstraction
`NamespaceResolver` теперь безопаснее для чтения, но это пока ещё не полноценный NamespaceManager с событиями, remove lifecycle и подписками.
Он всё ещё ближе к thread-safe registry, чем к полной orchestration layer.
### 4. Cleanup/restart/backfill lifecycle ещё не централизован
Часть компонентов уже ближе к единообразию, но по-прежнему нет одного центрального orchestration contract вида:
- add existing namespaces on startup;
- reconcile on relabel;
- remove on delete;
- rebuild after restart;
- re-register late component safely.
---
## Почему я не стал сразу делать remove/cleanup
Потому что это уже следующая категория сложности.
До step 6 изменения укладывались в схему:
- локальный и понятный дефект;
- ограниченный blast radius;
- тестируемый пакет;
- отдельный маленький commit.
Remove/cleanup меняет уже жизненный цикл системы и затрагивает много мест одновременно:
- watcher behavior;
- manager lifecycle;
- informer shutdown semantics;
- cache invalidation;
- resolver state.
Это не тот шаг, который разумно смешивать с небольшими safety fixes.
---
## Почему такая стратегия лучше, чем «переписать всё сразу»
Потому что сейчас уже есть видимый результат с низким риском:
1. Уменьшено число прямых доступов к общей mutable map.
2. Исправлен реальный functional bug в buildermgr.
3. Исправлена реальная логическая ошибка в serviceaccount namespace routing.
4. Исправлена несимметрия в newdeploy dynamic path.
5. Закрыта явная router race-surface.
И всё это не одним большим коммитом, а серией шагов с локальной верификацией.
Для инфраструктурного кода это важнее, чем «красивый большой rewrite», который сложно раскладывать при регрессиях.
---
## Какие проверки были прогнаны по ходу серии
После шагов запускались:
- `go test ./pkg/utils/...`
- `go test ./pkg/executor/...`
- `go test ./pkg/storagesvc/...`
- `go test ./pkg/buildermgr/...`
- `go test ./pkg/router/...`
Логика была такая:
1. Не гонять каждый раз всю репу, если шаг локальный.
2. Но обязательно проверять затронутый пакет и соседний пакет, если change касается shared utility layer.
---
## Текущее состояние после серии
Серия шагов 1-6 не завершает rewrite, но заметно улучшает базу для следующего этапа.
Что теперь стало лучше:
1. Namespace reads стали заметно более дисциплинированными.
2. Dynamic namespace onboarding стал логически ровнее между компонентами.
3. В router исчезла наиболее явная race-surface на informer maps.
4. Buildermgr больше не теряет часть подписок на новый namespace из-за неправильного dedup.
Что остаётся следующим осмысленным этапом:
1. Вынесение уже полноценного NamespaceManager как orchestration layer.
2. Remove/cleanup lifecycle.
3. Разделение discovery, registry и provisioning.
4. Дополнительные тесты на restart/relabel/delete/burst onboarding.
---
## Отдельная заметка про `serviceaccount.go`
На момент написания этого документа файл `pkg/utils/serviceaccount.go` был заново перечитан по текущему содержимому. Документ описывает актуальную логику файла в его текущем состоянии, а не только то состояние, которое было в момент коммита step 2.
Это важно, потому что именно в этом файле пользовательский контекст отдельно предупредил о возможных дополнительных изменениях между сообщениями.
@@ -0,0 +1,44 @@
# 2026-04-26 — NamespaceManager rewrite, step 1
## Цель шага
Начать bounded rewrite Layer 1 без большого взрыва по коду.
Первый шаг deliberately узкий:
- не менять lifecycle namespace onboarding;
- не трогать watcher-ы executor/router/buildermgr;
- не менять контракты `AddNamespace`;
- убрать первые прямые проходы по общей mutable map `FissionResourceNS`.
## Почему именно так
Сейчас multi-tenant логика уже динамическая, но многие старые code path все еще читают
`DefaultNSResolver().FissionResourceNS` напрямую. Это опасно по двум причинам:
1. map общая и mutable, а dynamic onboarding меняет ее во время работы процесса;
2. часть helper-ов и startup path продолжают жить как будто список namespace-ов immutable.
Полный rewrite в один шаг дал бы слишком большой blast radius. Поэтому сначала вводится
thread-safe snapshot API в namespace layer, а затем существующие потребители переводятся
на него по одному.
## План шага 1
1. Добавить в `pkg/utils/namespace.go` методы snapshot для plain namespaces и namespaces with options.
2. Перевести `pkg/utils/informer.go` на snapshot API.
3. Перевести startup factory path в `pkg/executor/executor.go` на snapshot API.
4. Добавить unit tests для snapshot behavior.
5. Прогнать `go test ./pkg/utils/... ./pkg/executor/...`.
## Ожидаемый эффект
- меньше прямых чтений общей map;
- появление базового API, через который дальше можно выносить единый NamespaceManager;
- нулевое изменение внешнего поведения на этом шаге.
## Что НЕ делаем на этом шаге
- не исправляем watcher lifecycle;
- не добавляем remove/delete semantics;
- не трогаем router race и buildermgr dedup bug;
- не меняем RBAC.
@@ -0,0 +1,22 @@
# 2026-04-26 — NamespaceManager rewrite, step 10
## Цель шага
Научить skeleton manager выводить общую phase namespace-а из part states.
## Что меняем
1. Добавляем константы состояний частей:
- `registering`
- `active`
- `failed`
2. После `MarkPartState()` manager пересчитывает общую phase namespace-а.
3. Добавляем unit tests на переходы:
- registering -> active
- failed -> NamespacePhaseFailed
## Что НЕ меняем
- не запускаем реальный reconcile loop;
- не вызываем subscriber-ов автоматически;
- не подключаем manager к runtime.
@@ -0,0 +1,18 @@
# 2026-04-26 — NamespaceManager rewrite, step 11
## Цель шага
Добавить bootstrap helper для массовой загрузки initial namespace set в manager.
## Что меняем
1. Добавляем `Bootstrap()` в manager interface и реализацию.
2. Метод принимает список namespace-ов и `NamespaceSource`.
3. Метод прогоняет namespaces через `Upsert()` как initial discovered set.
4. Добавляем unit tests на bootstrap.
## Что НЕ меняем
- не подключаем bootstrap к runtime startup path;
- не меняем watcher-ы;
- не трогаем resolver/SA/runtime.
@@ -0,0 +1,17 @@
# 2026-04-26 — NamespaceManager rewrite, step 12
## Цель шага
Добавить bridge helper между legacy `NamespaceResolver` и новым `NamespaceManager`.
## Что меняем
1. Добавляем helper `NewBootstrappedNamespaceManager()`.
2. Helper берёт snapshot из resolver и bootstraps manager.
3. Добавляем unit test на bootstrap from resolver.
## Что НЕ меняем
- не подключаем helper к production startup path;
- не меняем watcher-ы;
- не меняем runtime components.
@@ -0,0 +1,20 @@
# 2026-04-26 — NamespaceManager rewrite, step 13
## Цель шага
Централизовать managed namespace label contract в `utils`.
## Что меняем
1. Добавляем в `utils`:
- `ManagedNamespaceLabelKey`
- `ManagedNamespaceLabelValue`
- `ManagedNamespaceLabelSelector()`
- `IsManagedNamespace()`
2. Переводим watcher-ы executor/router/buildermgr на единый helper.
## Что НЕ меняем
- не подключаем новый manager к watcher-ам;
- не меняем поведение onboarding;
- не трогаем runtime reconcile.
@@ -0,0 +1,19 @@
# 2026-04-26 — NamespaceManager rewrite, step 14
## Цель шага
Добавить удобные helper-методы для part-state transitions.
## Что меняем
1. В manager interface добавляем:
- `MarkPartRegistering()`
- `MarkPartActive()`
- `MarkPartFailed()`
2. Реализуем их поверх `MarkPartState()`.
3. Добавляем unit tests.
## Что НЕ меняем
- не подключаем helpers к runtime reconcile;
- не трогаем watcher-ы и runtime components.
@@ -0,0 +1,16 @@
# 2026-04-26 — NamespaceManager rewrite, step 15
## Цель шага
Добавить utility helper-методы для построения `NamespaceEvent`.
## Что меняем
1. Добавляем `NewNamespaceEvent()`.
2. Добавляем `ManagedNamespaceEvent()`.
3. Добавляем unit tests.
## Что НЕ меняем
- не подключаем event helpers к watcher-ам;
- не меняем runtime behavior.
@@ -0,0 +1,18 @@
# 2026-04-26 — NamespaceManager rewrite, step 16
## Цель шага
Подготовить lifecycle subscriber contract для будущего reconcile path.
## Что меняем
1. Расширяем `NamespaceSubscriber` методами:
- `OnNamespaceAdd()`
- `OnNamespaceRemove()`
- `OnNamespaceResync()`
2. Обновляем тестовую заглушку subscriber-а.
## Что НЕ меняем
- не вызываем subscriber-ов из manager;
- не подключаем contract к runtime components.
@@ -0,0 +1,22 @@
# 2026-04-26 — NamespaceManager rewrite, step 17
## Цель шага
Добавить dispatch helper для прогона namespace через subscriber-ов в add/resync path.
## Что меняем
1. В manager interface добавляем:
- `DispatchAdd()`
- `DispatchResync()`
2. Manager вызывает subscriber-ов последовательно.
3. Для каждого subscriber-а manager проставляет part state:
- `registering`
- `active` или `failed`
4. Добавляем unit tests на success и failure path.
## Что НЕ меняем
- не подключаем dispatch к production watcher-ам;
- не добавляем remove dispatch;
- не меняем runtime components.
@@ -0,0 +1,15 @@
# 2026-04-26 — NamespaceManager rewrite, step 18
## Цель шага
Подготовить watcher-friendly helper для преобразования Kubernetes Namespace в `NamespaceEvent`.
## Что меняем
1. Добавляем `NamespaceEventFromNamespace()`.
2. Добавляем unit tests на перенос имени и labels.
## Что НЕ меняем
- не подключаем helper к watcher-ам;
- не меняем runtime behavior.
@@ -0,0 +1,16 @@
# 2026-04-26 — NamespaceManager rewrite, step 19
## Цель шага
Добавить functional adapter для `NamespaceSubscriber`.
## Что меняем
1. Добавляем `NamespaceSubscriberFuncs`.
2. Добавляем `Name()/OnNamespaceAdd()/OnNamespaceRemove()/OnNamespaceResync()`.
3. Добавляем unit tests.
## Что НЕ меняем
- не подключаем adapter к runtime;
- не меняем production watcher-ы.
@@ -0,0 +1,33 @@
# 2026-04-26 — NamespaceManager rewrite, step 2
## Цель шага
Убрать еще один прямой проход по `FissionResourceNS` и закрыть конкретный баг в
`pkg/utils/serviceaccount.go`.
## Проблема
`runSACheck()` сейчас:
1. итерируется по `sa.nsResolver.FissionResourceNS` напрямую;
2. переиспользует переменную `ns` внутри внутреннего цикла по permissions.
Из-за этого код выглядит безобидно, но фактически смешивает два разных namespace path:
- fetcher path через `GetFunctionNS()`;
- builder path через `GetBuilderNS()`.
Если `FunctionNamespace` и `BuilderNamespace` различаются, builder SA может начать
резолвиться уже не от исходного namespace, а от результата предыдущего шага цикла.
## Что меняем
1. Берем base namespaces через thread-safe `Snapshot()`.
2. Для каждого permission вычисляем `targetNS` из исходного `baseNS`, а не из мутированной переменной.
3. Добавляем unit test на routing function/builder namespace.
## Что НЕ меняем на этом шаге
- не трогаем глобальные `fetcherCheck` / `builderCheck` структуры;
- не меняем `LocalSubjectAccessReview` path;
- не делаем большой refactor всего SA provisioning.
@@ -0,0 +1,21 @@
# 2026-04-26 — NamespaceManager rewrite, step 20
## Цель шага
Сделать первый реальный runtime adapter для `NamespaceManager` в `buildermgr`.
## Что меняем
1. Добавляем buildermgr namespace subscriber.
2. Adapter переиспользует существующие `envWatcher.AddNamespace()` и `packageWatcher.AddNamespace()`.
3. `add/resync` path повторяет текущую логику watcher-а:
- добавить namespace в resolver;
- вызвать env watcher;
- вызвать package watcher.
4. Добавляем unit test на вызов обоих watcher-ов.
## Что НЕ меняем
- не подключаем subscriber к `StartNSWatcher()`;
- не меняем remove behavior;
- не ломаем текущий production flow.
@@ -0,0 +1,15 @@
# 2026-04-26 — NamespaceManager rewrite, step 21
## Цель шага
Свести текущий watcher flow и новый subscriber flow `buildermgr` к одному helper.
## Что меняем
1. `buildermgr/ns_watcher.go` больше не дублирует логику add/resync.
2. Watcher вызывает `registerBuilderNamespace()`.
## Что НЕ меняем
- не меняем внешний API watcher-а;
- не переключаем `StartNSWatcher()` на `NamespaceManager`.
@@ -0,0 +1,17 @@
# 2026-04-26 — NamespaceManager rewrite, step 22
## Цель шага
Добавить первый runtime adapter для `router` по тому же шаблону, что и для `buildermgr`.
## Что меняем
1. Добавляем router namespace subscriber.
2. Adapter переиспользует существующий `HTTPTriggerSet.AddNamespace()`.
3. `add/resync` path прогоняется через общий helper.
## Что НЕ меняем
- не подключаем subscriber к `StartNSWatcher()`;
- не меняем remove path;
- не меняем текущий production flow.
@@ -0,0 +1,15 @@
# 2026-04-26 — NamespaceManager rewrite, step 23
## Цель шага
Свести текущий watcher flow и новый subscriber flow `router` к одному helper.
## Что меняем
1. `router/ns_watcher.go` больше не дублирует add/resync логику.
2. Watcher вызывает `registerRouterNamespace()`.
## Что НЕ меняем
- не переключаем `StartNSWatcher()` на `NamespaceManager`;
- не меняем внешний API watcher-а.
@@ -0,0 +1,16 @@
# 2026-04-26 — NamespaceManager rewrite, step 24
## Цель шага
Подготовить `executor/multitenant` к subscriber adapter без смены текущего watcher behavior.
## Что меняем
1. Выделяем отдельный helper для прогона `AddNamespace()` по executor type-ам.
2. Оставляем `EnsureNamespaceSA()` в текущем `registerNamespace()`.
3. Добавляем unit test на успешный прогон и propagation ошибок.
## Что НЕ меняем
- не подключаем `NamespaceManager`;
- не меняем внешний API watcher-а.
@@ -0,0 +1,17 @@
# 2026-04-26 — NamespaceManager rewrite, step 25
## Цель шага
Добавить runtime adapter для `executor/multitenant` поверх уже выделенного helper-а.
## Что меняем
1. Добавляем executor namespace subscriber.
2. `add/resync` path переиспользует `registerNamespace()`.
3. Добавляем unit test на вызов executor type-ов.
## Что НЕ меняем
- не подключаем subscriber к watcher-у;
- не меняем remove path;
- не меняем внешний API watcher-а.
@@ -0,0 +1,17 @@
# 2026-04-26 — NamespaceManager rewrite, step 26
## Цель шага
Добавить единый startup bridge для manager: bootstrap model + dispatch в subscriber-ы.
## Что меняем
1. В `NamespaceManager` добавляем `BootstrapAndDispatch()`.
2. Helper сначала делает `Bootstrap()`, потом вызывает `DispatchAdd()` по каждому namespace.
3. Ошибки агрегируются и не останавливают остальные namespace.
4. Добавляем unit tests на success и partial-failure.
## Что НЕ меняем
- не подключаем helper к production startup path;
- не меняем watcher behavior.
@@ -0,0 +1,20 @@
# 2026-04-26 — NamespaceManager rewrite, step 27
## Цель шага
Сделать первый реальный runtime hook на `NamespaceManager` в `buildermgr` watcher.
## Что меняем
1. `buildermgr.StartNSWatcher()` поднимает локальный `NamespaceManager`.
2. В manager заранее bootstrapped текущий snapshot resolver-а.
3. Watcher `Add/Update` события прогоняет через:
- `Upsert()`
- `DispatchAdd()` или `DispatchResync()`
4. Подписчиком manager-а становится уже существующий `buildermgr` subscriber adapter.
## Что НЕ меняем
- не меняем `registerBuilderNamespace()`;
- не добавляем remove path;
- не меняем остальные компоненты.
@@ -0,0 +1,19 @@
# 2026-04-26 — NamespaceManager rewrite, step 28
## Цель шага
Сделать такой же runtime hook на `NamespaceManager` в `router` watcher.
## Что меняем
1. `router.StartNSWatcher()` поднимает локальный `NamespaceManager`.
2. Manager bootstrapped из текущего resolver snapshot.
3. Watcher `Add/Update` события прогоняет через:
- `Upsert()`
- `DispatchAdd()` или `DispatchResync()`
4. Подписчиком manager-а становится router subscriber adapter.
## Что НЕ меняем
- не добавляем remove path;
- не меняем `HTTPTriggerSet.AddNamespace()`.
@@ -0,0 +1,19 @@
# 2026-04-26 — NamespaceManager rewrite, step 29
## Цель шага
Перевести `executor/multitenant` watcher на тот же manager flow, что уже используется в `buildermgr` и `router`.
## Что меняем
1. `StartNSWatcher()` поднимает локальный `NamespaceManager`.
2. Manager bootstrapped из resolver snapshot.
3. Watcher `Add/Update` события прогоняет через:
- `Upsert()`
- `DispatchAdd()` или `DispatchResync()`
4. Подписчиком manager-а становится executor subscriber adapter.
## Что НЕ меняем
- не добавляем remove path;
- не меняем `registerNamespace()` и низкоуровневый executor registration helper.
@@ -0,0 +1,28 @@
# 2026-04-26 — NamespaceManager rewrite, step 3
## Цель шага
Срезать еще один слой прямых чтений `DefaultNSResolver().FissionResourceNS` в runtime code path.
## Почему это отдельный шаг
После step 1 snapshot API уже существует, но runtime loops в executor и storagesvc все еще
читают общую mutable map напрямую. Это не архитектурный rewrite, а чистый safety refactor:
- `container.AdoptExistingResources()`
- `newdeploy.AdoptExistingResources()`
- `newdeploy.doIdleObjectReaper()`
- `poolmgr.AdoptExistingResources()`
- `poolmgr.doIdleObjectReaper()`
- `storagesvc.ArchivePruner.getOrphanArchives()`
## Что меняем
В этих местах цикл переводится на `DefaultNSResolver().Snapshot()`.
## Что НЕ меняем
- не меняем семантику cleanup;
- не меняем behavior watcher-ов;
- не добавляем remove semantics;
- не исправляем router race и buildermgr dedup на этом шаге.
@@ -0,0 +1,15 @@
# 2026-04-26 — NamespaceManager rewrite, step 30
## Цель шага
Закрыть startup gap в `buildermgr`: manager должен отражать и существующие namespace-ы, а не только новые события watcher-а.
## Что меняем
1. `buildermgr.StartNSWatcher()` создаёт пустой `NamespaceManager`.
2. После `Subscribe()` выполняется `BootstrapAndDispatch()` по текущему snapshot resolver-а.
## Что НЕ меняем
- не меняем low-level registration helper;
- не меняем remove path.
@@ -0,0 +1,15 @@
# 2026-04-26 — NamespaceManager rewrite, step 31
## Цель шага
Закрыть startup gap в `router`: локальный manager должен отражать существующие namespace-ы уже на старте.
## Что меняем
1. `router.StartNSWatcher()` создаёт пустой `NamespaceManager`.
2. После `Subscribe()` выполняется `BootstrapAndDispatch()` по snapshot resolver-а.
## Что НЕ меняем
- не меняем `HTTPTriggerSet.AddNamespace()`;
- не добавляем remove path.
@@ -0,0 +1,15 @@
# 2026-04-26 — NamespaceManager rewrite, step 32
## Цель шага
Закрыть startup gap в `executor/multitenant`: manager должен отражать стартовые namespace-ы и прогонять их через тот же subscriber path.
## Что меняем
1. `StartNSWatcher()` создаёт пустой `NamespaceManager`.
2. После `Subscribe()` выполняется `BootstrapAndDispatch()` по snapshot resolver-а.
## Что НЕ меняем
- не меняем `registerNamespace()`;
- не добавляем remove path.
@@ -0,0 +1,17 @@
# 2026-04-26 — NamespaceManager rewrite, step 33
## Цель шага
Убрать несоответствие между contract и manager implementation: `OnNamespaceRemove()` уже есть, а `DispatchRemove()` ещё нет.
## Что меняем
1. В `NamespaceManager` добавляем `DispatchRemove()`.
2. Manager вызывает `OnNamespaceRemove()` у всех subscriber-ов.
3. После dispatch namespace переводится в `removed` через `NamespaceEventRemove`.
4. Добавляем unit tests на success и failure path.
## Что НЕ меняем
- не подключаем remove events в watcher-ы;
- не реализуем physical cleanup в runtime components.
@@ -0,0 +1,19 @@
# 2026-04-26 — NamespaceManager rewrite, step 34
## Цель шага
Подготовить безопасный helper для delete/tombstone событий Namespace informer-а.
## Что меняем
1. Добавляем `NamespaceFromObject()`.
2. Helper поддерживает:
- `*corev1.Namespace`
- `cache.DeletedFinalStateUnknown`
3. Добавляем `NamespaceEventFromObject()`.
4. Добавляем unit tests.
## Что НЕ меняем
- не подключаем delete handling в watcher-ы на этом шаге;
- не меняем runtime behavior.
@@ -0,0 +1,20 @@
# 2026-04-26 — NamespaceManager rewrite, step 35
## Цель шага
Научить watcher-ы фиксировать label-drop/delete в локальном `NamespaceManager`, не трогая реальные runtime регистрации.
## Что меняем
1. Во все три namespace watcher-а добавляем:
- `DeleteFunc`
- обработку `managed -> unmanaged` в `UpdateFunc`
2. При таком событии watcher:
- создаёт `NamespaceEventRemove`
- записывает его в manager через `Upsert()`
- пишет явный log, что runtime cleanup НЕ выполняется
## Что НЕ меняем
- не вызываем `DispatchRemove()` из watcher-ов;
- не удаляем informer-ы, resolver state или runtime registrations.
@@ -0,0 +1,18 @@
# 2026-04-26 — NamespaceManager rewrite, step 36
## Цель шага
Убрать мёртвый код после перевода watcher-ов на `NamespaceManager` flow.
## Что меняем
1. Удаляем неиспользуемые helper-ы:
- `builderNSName()`
- `routerNSName()`
- `namespaceName()`
2. Убираем ставшие неиспользуемыми imports.
## Что НЕ меняем
- не меняем runtime behavior;
- не меняем watcher logic.
@@ -0,0 +1,19 @@
# 2026-04-26 — NamespaceManager rewrite, step 37
## Цель шага
Убрать дублирование startup manager flow в трёх namespace watcher-ах.
## Что меняем
1. В `utils` добавляем helper `NewWatcherNamespaceManager()`.
2. Helper:
- создаёт `NamespaceManager`
- подписывает subscriber-ов
- выполняет `BootstrapAndDispatch()`
3. `buildermgr`, `router`, `executor/multitenant` используют новый helper.
## Что НЕ меняем
- не меняем semantics dispatch;
- не меняем runtime cleanup policy.
@@ -0,0 +1,19 @@
# 2026-04-26 — NamespaceManager rewrite, step 38
## Цель шага
Убрать повторяющуюся lifecycle логiku namespace watcher-ов.
## Что меняем
1. В `utils` добавляем helpers:
- `NamespaceBecameUnmanaged()`
- `DispatchNamespaceAdd()`
- `DispatchNamespaceResync()`
- `RecordNamespaceRemoval()`
2. `buildermgr`, `router`, `executor/multitenant` используют эти helpers.
## Что НЕ меняем
- не меняем runtime semantics;
- remove по-прежнему только bookkeeping, без cleanup.
@@ -0,0 +1,43 @@
# 2026-04-26 — NamespaceManager rewrite, step 39
## Цель шага
Свести три namespace watcher-а к одинаковому lifecycle поведению через общие handlers в `utils`.
## Что меняем
1. Добавляем helpers:
- `HandleWatcherNamespaceAdd()`
- `HandleWatcherNamespaceUpdate()`
- `HandleWatcherNamespaceDelete()`
2. Helpers централизуют:
- dispatch add/resync;
- remove bookkeeping;
- стандартное logging-сообщение.
3. `buildermgr`, `router`, `executor/multitenant` переходят на эти helpers.
## Что НЕ меняем
- не меняем runtime cleanup policy;
- не меняем manager state model.# 2026-04-26 — NamespaceManager rewrite, step 39
## Цель шага
Свести три namespace watcher-а к одинаковому lifecycle поведению через общие handlers в `utils`.
## Что меняем
1. Добавляем helpers:
- `HandleWatcherNamespaceAdd()`
- `HandleWatcherNamespaceUpdate()`
- `HandleWatcherNamespaceDelete()`
2. Helpers централизуют:
- dispatch add/resync;
- remove bookkeeping;
- стандартное logging-сообщение.
3. `buildermgr`, `router`, `executor/multitenant` переходят на эти helpers.
## Что НЕ меняем
- не меняем runtime cleanup policy;
- не меняем manager state model.
@@ -0,0 +1,32 @@
# 2026-04-26 — NamespaceManager rewrite, step 4
## Цель шага
Исправить реальный functional bug в dynamic onboarding buildermgr.
## Дефект
`buildermgr.StartNSWatcher()` вызывает:
1. `envw.AddNamespace()`
2. `pkgw.AddNamespace()`
Но оба watcher-а используют один и тот же глобальный `nsResolver.AddNamespace()` для dedup.
Из-за этого первый вызов добавляет namespace, а второй считает его уже обработанным и
выходит раньше времени. В результате у динамического tenant namespace может подняться только
Environment informer без Package informer.
## Исправление
1. Глобальный resolver обновляется один раз в `buildermgr/ns_watcher.go`.
2. `environmentWatcher` dedup делает только по своей map `envWatchInformer`.
3. `packageWatcher` dedup делает только по своим map `pkgInformer` / `podInformer`.
Так buildermgr становится симметричнее executor path: общий registry обновляется один раз,
а конкретные компоненты сами решают, подписаны ли они уже на namespace.
## Что НЕ меняем
- не добавляем cleanup/remove semantics;
- не меняем router;
- не трогаем newdeploy parity gap на этом шаге.
@@ -0,0 +1,20 @@
# 2026-04-26 — NamespaceManager rewrite, step 40
## Цель шага
Зафиксировать lifecycle policy для namespace removal в коде явно, а не только комментариями и log-сообщениями.
## Что меняем
1. Добавляем `NamespaceRemovalStrategy`.
2. Поддерживаем два режима:
- `track-only`
- `dispatch-remove`
3. Общие watcher handlers принимают strategy.
4. Текущий production flow использует `track-only`.
5. Добавляем unit tests на оба режима.
## Что НЕ меняем
- не включаем реальный remove dispatch в watcher-ах;
- не меняем runtime cleanup policy по умолчанию.
@@ -0,0 +1,16 @@
# 2026-04-26 — NamespaceManager rewrite, step 41
## Цель шага
Довести explicit removal strategy до полного покрытия watcher lifecycle paths.
## Что меняем
1. `HandleWatcherNamespaceUpdate()` теперь тоже принимает `NamespaceRemovalStrategy`.
2. `managed -> unmanaged` path использует ту же policy, что и `DeleteFunc`.
3. Добавляем unit test на update-path с `dispatch-remove`.
## Что НЕ меняем
- текущие watcher-ы остаются на `track-only`;
- runtime cleanup policy по умолчанию не меняется.
@@ -0,0 +1,17 @@
# 2026-04-26 — NamespaceManager rewrite, step 42
## Цель шага
Убрать последний крупный слой дублирования в namespace watcher-ах: сами `ResourceEventHandlerFuncs`.
## Что меняем
1. В `utils` добавляем `NewNamespaceWatcherEventHandlers()`.
2. Конструктор собирает общий `Add/Update/Delete` flow на базе уже существующих handler helper-ов.
3. `buildermgr`, `router`, `executor/multitenant` используют общий конструктор.
## Что НЕ меняем
- не меняем label selector;
- не меняем manager semantics;
- не меняем removal policy по умолчанию.
@@ -0,0 +1,19 @@
# 2026-04-26 — NamespaceManager rewrite, step 43
## Цель шага
Убрать оставшуюся копипасту старта namespace informer-а из `buildermgr`, `router`, `executor/multitenant`.
## Что меняем
1. В `utils` добавляем `StartManagedNamespaceWatcher()`.
2. Helper централизует:
- informer factory с label selector;
- регистрацию event handlers;
- start/cache sync/stop logging через `mgr`.
3. Три watcher-а переходят на общий helper.
## Что НЕ меняем
- не меняем lifecycle logic;
- не меняем selector contract `fission.io/managed=true`.
@@ -0,0 +1,20 @@
# 2026-04-26 — NamespaceManager rewrite, step 44
## Цель шага
Убрать последний дублирующийся orchestration-код из `StartNSWatcher()` в трёх компонентах.
## Что меняем
1. В `utils` добавляем `PrepareManagedNamespaceWatcher()`.
2. Helper:
- создаёт `NamespaceManager`;
- делает bootstrap+dispatch;
- собирает общие event handlers.
3. `buildermgr`, `router`, `executor/multitenant` используют этот helper.
## Что НЕ меняем
- не меняем subscriber logic;
- не меняем managed namespace watcher startup helper;
- не меняем removal strategy по умолчанию.
@@ -0,0 +1,20 @@
# 2026-04-26 — NamespaceManager rewrite, step 45
## Цель шага
Подготовить компактный status/debug surface для `NamespaceManager`.
## Что меняем
1. Добавляем `NamespaceManagerSummary`.
2. В `NamespaceManager` добавляем `Summary()`.
3. Summary считает:
- общее число namespace-ов;
- число по phase;
- список subscriber-ов.
4. Добавляем unit tests.
## Что НЕ меняем
- не публикуем summary наружу через HTTP;
- не меняем watcher behavior.
@@ -0,0 +1,33 @@
# 2026-04-26 — NamespaceManager rewrite, step 46
## Цель шага
Закрыть маленький пробел в debug surface: `LogNamespaceManagerSummary()` уже используется, но отдельно не тестируется.
## Что меняем
1. Добавляем unit test на `LogNamespaceManagerSummary()`.
2. Проверяем, что helper безопасен на `nil` logger и не паникует на заполненном summary.
## Что НЕ меняем
- не меняем runtime behavior;
- не публикуем summary наружу через HTTP.# 2026-04-26 — NamespaceManager rewrite, step 46
## Цель шага
Начать реальное использование `NamespaceManager.Summary()` в orchestration layer.
## Что меняем
1. Добавляем helper `LogNamespaceManagerSummary()`.
2. `PrepareManagedNamespaceWatcher()` пишет summary после bootstrap.
3. В лог попадают:
- общее число namespace-ов;
- subscriber-ы;
- phase counts.
## Что НЕ меняем
- не экспортируем summary наружу через HTTP;
- не меняем runtime behavior watcher-ов.
@@ -0,0 +1,17 @@
# 2026-04-26 — NamespaceManager rewrite, step 47
## Цель шага
Сделать `NamespaceManagerSummary` информативнее для наблюдения за источниками namespace state.
## Что меняем
1. В summary добавляем `SourceCounts`.
2. `Summary()` считает namespace-ы по `NamespaceSource`.
3. `LogNamespaceManagerSummary()` пишет `source_counts`.
4. Обновляем unit tests.
## Что НЕ меняем
- не меняем watcher behavior;
- не меняем semantics state transitions.
@@ -0,0 +1,33 @@
# 2026-04-26 — NamespaceManager rewrite, step 48
## Цель шага
Добавить маленький, но полезный helper поверх summary/debug contract: проверку, есть ли вообще живые namespace-ы.
## Что меняем
1. В `NamespaceManagerSummary` добавляем `HasActiveNamespaces()`.
2. Добавляем unit tests на true/false path.
## Что НЕ меняем
- не меняем summary counters;
- не меняем watcher behavior.# 2026-04-26 — NamespaceManager rewrite, step 48
## Цель шага
Убрать двусмысленность в `NamespaceManagerSummary`: сейчас `TotalNamespaces` включает и removed-записи.
## Что меняем
1. Добавляем `LiveNamespaces`.
2. `Summary()` считает его по `Snapshot()`.
3. `LogNamespaceManagerSummary()` пишет оба значения:
- `total_namespaces`
- `live_namespaces`
4. Обновляем unit tests.
## Что НЕ меняем
- не меняем правила хранения removed records;
- не меняем watcher behavior.
@@ -0,0 +1,35 @@
# 2026-04-26 — NamespaceManager rewrite, step 49
## Цель шага
Довести `HasActiveNamespaces()` до реального use-site, чтобы helper не оставался чисто декларативным.
## Что изменено
1. `LogNamespaceManagerSummary()` теперь пишет флаг `has_active_namespaces`.
2. Добавлен unit test на presence и значение этого поля в structured log.
## Почему это безопасно
- watcher behavior не меняется;
- изменён только debug/logging contract;
- покрыто `go test ./pkg/utils/...`.# 2026-04-26 — NamespaceManager rewrite, step 49
## Цель шага
Собрать `prepare + start` managed namespace watcher в один общий entrypoint.
## Что меняем
1. Добавляем `RunManagedNamespaceWatcher()`.
2. Helper:
- готовит manager;
- строит handlers;
- запускает managed namespace informer.
3. Три `StartNSWatcher()` переходят на новый entrypoint.
4. Добавляем минимальный unit test с fake client.
## Что НЕ меняем
- не меняем subscriber logic;
- не меняем selector/strategy semantics.
@@ -0,0 +1,29 @@
# 2026-04-26 — NamespaceManager rewrite, step 5
## Цель шага
Исправить несимметрию между startup-path и dynamic namespace onboarding в `newdeploy` executor.
## Дефект
На старте `MakeNewDeploy()` регистрирует два вида обработчиков на Fission informers:
- `FunctionEventHandlers()`
- `EnvEventHandlers()`
Но dynamic `AddNamespace()` регистрировал только `FunctionEventHandlers()`.
Это означало, что namespace, появившийся после старта процесса, обслуживается не тем же
код-path, что namespace, известный на старте. Для multi-tenant Layer 1 это плохая семантика:
часть поведения newdeploy зависит не от namespace, а от момента его появления.
## Исправление
В `AddNamespace()` добавляется регистрация `EnvEventHandlers()` перед запуском informer factory.
## Что НЕ меняем
- не меняем container executor;
- не меняем poolmgr;
- не добавляем remove semantics;
- не меняем router.
@@ -0,0 +1,32 @@
# 2026-04-26 — NamespaceManager rewrite, step 50
## Цель шага
Сделать summary/debug surface полезным в реальном watcher lifecycle, а не только на этапе подготовки manager-а.
## Что изменено
1. После успешных add/resync/remove transitions watcher helpers теперь пишут компактный summary manager-а.
2. Добавлен unit test на add-handler path с проверкой structured-log полей.
## Что это даёт
- runtime behavior не меняется;
- появляется последовательный debug trail по изменению manager state;
- новый helper `HasActiveNamespaces()` теперь используется и в general logging path, и в watcher transition path.# 2026-04-26 — NamespaceManager rewrite, step 50
## Цель шага
Сделать orchestration API для managed namespace watcher-а жёстче и читабельнее.
## Что меняем
1. Добавляем `ManagedNamespaceWatcherConfig`.
2. `PrepareManagedNamespaceWatcher()` и `RunManagedNamespaceWatcher()` принимают config struct.
3. Если strategy не задана, используется `track-only`.
4. Обновляем unit tests и call sites.
## Что НЕ меняем
- не меняем runtime semantics;
- не меняем subscriber logic.
@@ -0,0 +1,33 @@
# 2026-04-26 — NamespaceManager rewrite, step 51
## Цель шага
Закрыть observability gap между `prepared namespace manager` и runtime transition logs.
## Что изменено
1. `RunManagedNamespaceWatcher()` теперь пишет единый summary log после старта watcher-а.
2. Добавлен unit test на startup logging path.
## Почему это полезно
- buildermgr, router и executor получают одинаковый startup debug signal без копипасты;
- видно состояние manager-а в момент, когда watcher уже реально подключён;
- runtime semantics не меняется.# 2026-04-26 — NamespaceManager rewrite, step 51
## Цель шага
Убрать из call sites повторение стандартного config для managed namespace watcher-а.
## Что меняем
1. Добавляем `NewDefaultManagedNamespaceWatcherConfig()`.
2. Helper подставляет:
- `DefaultNSResolver().Snapshot()`;
- `track-only` как default removal strategy.
3. `buildermgr`, `router`, `executor/multitenant` используют helper.
## Что НЕ меняем
- не меняем runtime semantics;
- не меняем subscriber logic.
@@ -0,0 +1,17 @@
# 2026-04-26 — NamespaceManager rewrite, step 52
## Цель шага
Убрать хрупкость общего watcher path, где `nil` logger мог привести к panic на error/info ветках.
## Что изменено
1. Введена централизованная нормализация logger-а к `zap.NewNop()`.
2. Hardening применён к prepare/run/start и watcher event handlers.
3. Добавлены regression tests на nil-logger path.
## Почему это важно
- это уже runtime hardening, а не декоративный cleanup;
- общий helper layer стал безопаснее для повторного использования;
- поведение watcher-ов не меняется, меняется только устойчивость logging path.
@@ -0,0 +1,24 @@
# 2026-04-26 — NamespaceManager rewrite, step 53
## Итог step1
`rewrite/layer1-namespace-manager-step1` можно считать завершённым как отдельный этап.
## Критерии, которые теперь выполнены
1. Общий `NamespaceManager` и watcher orchestration вынесены в `pkg/utils`.
2. Buildermgr, router и executor/multitenant используют общий helper layer вместо прежней разрозненной lifecycle-логики.
3. Summary/debug contract стабилизирован и покрыт тестами.
4. Logging path усилен: есть prepare/start/transition summary logs и nil-logger hardening.
## Финальная проверка этапа
Пройден целевой набор:
`go test ./pkg/utils/... ./pkg/buildermgr/... ./pkg/router/... ./pkg/executor/multitenant`
Все пакеты зелёные.
## Что дальше
Следующий этап должен быть уже не про внутреннюю консолидацию watcher layer, а про внешний consumption этой модели: status/debug surface, integration behavior или следующий слой rewrite.
@@ -0,0 +1,34 @@
# 2026-04-26 — NamespaceManager rewrite, step 6
## Цель шага
Закрыть race-surface в router вокруг динамического добавления namespace informer-ов.
## Проблема
В router есть два связанных mutable map:
- `HTTPTriggerSet.triggerInformer`
- `HTTPTriggerSet.funcInformer`
`AddNamespace()` пишет в них на лету, а `updateRouter()` одновременно итерируется по ним.
Кроме того, `functionReferenceResolver` получает `funcInformer` map и читает ее без синхронизации.
Это делает dynamic onboarding потенциальным источником:
- `concurrent map iteration and map write`;
- чтения неполного снимка informer-ов;
- гонок между router rebuild и resolver lookup.
## Исправление
1. В `HTTPTriggerSet` добавляется `RWMutex` для informer maps.
2. Чтение informer-ов переводится на snapshot helpers.
3. `functionReferenceResolver` получает собственный lock и метод `addInformer()`.
4. `router.AddNamespace()` обновляет router map и resolver map под контролируемым доступом.
## Что НЕ меняем
- не переписываем router lifecycle целиком;
- не добавляем remove semantics;
- не меняем trigger/function business logic.
@@ -0,0 +1,36 @@
# 2026-04-26 — NamespaceManager rewrite, step 7
## Цель шага
Добавить минимальную модель данных для будущего `NamespaceManager`, не меняя пока production wiring.
## Почему это отдельный шаг
После шагов 1-6 уже стало ясно, что следующая стадия — не ещё один patch по месту, а переход к явной модели lifecycle.
Но сразу подключать новый manager к watcher-ам и компонентам рано. Сначала нужна опорная модель:
- `NamespacePhase`
- `NamespaceSource`
- `NamespaceEventType`
- `NamespaceRecord`
- `NamespacePartState`
## Что меняем
1. Добавляем новый файл с типами model layer.
2. Добавляем helper-методы:
- `Clone()`
- `IsActive()`
- `IsTerminal()`
3. Добавляем unit tests на:
- корректный deep copy;
- active semantics;
- terminal semantics.
## Что НЕ меняем
- не подключаем manager к production path;
- не меняем watcher-ы;
- не меняем resolver;
- не затрагиваем текущее изменение в `serviceaccount.go`.
@@ -0,0 +1,25 @@
# 2026-04-26 — NamespaceManager rewrite, step 8
## Цель шага
Добавить skeleton `NamespaceManager` с in-memory state и unit tests.
## Что меняем
1. Добавляем interface `NamespaceManager`.
2. Добавляем in-memory реализацию с mutex.
3. Добавляем операции:
- `Snapshot()`
- `SnapshotRecords()`
- `Get()`
- `Upsert()`
- `MarkPartState()`
- `Remove()`
4. Добавляем unit tests на snapshot/get/upsert/remove/part-state.
## Что НЕ меняем
- не подключаем manager к watcher-ам;
- не меняем текущий resolver path;
- не трогаем runtime components;
- не затрагиваем отдельное незакоммиченное изменение в `serviceaccount.go`.
@@ -0,0 +1,20 @@
# 2026-04-26 — NamespaceManager rewrite, step 9
## Цель шага
Добавить subscriber contract в `NamespaceManager`, не подключая его пока к runtime.
## Что меняем
1. Добавляем interface `NamespaceSubscriber`.
2. Добавляем в manager операции:
- `Subscribe()`
- `SnapshotSubscribers()`
3. Добавляем unit tests на регистрацию и snapshot subscriber-ов.
## Что НЕ меняем
- не вызываем subscriber-ов из watcher-ов;
- не строим reconcile loop;
- не трогаем runtime components;
- не затрагиваем внешнее изменение в `serviceaccount.go`.
@@ -0,0 +1,697 @@
# 2026-04-26 — Target design: полноценный NamespaceManager для Layer 1
## Зачем нужен ещё один документ
Уже есть подробный документ про сделанные шаги 1-6.
Но этого недостаточно для следующего этапа, потому что:
1. История исправлений не равна целевой архитектуре.
2. Локальные фиксы уже уменьшили риск, но не дали единого lifecycle contract.
3. Следующий этап уже нельзя начинать как серию хаотичных патчей по месту.
Нужен отдельный документ, который отвечает на вопрос:
какой именно Layer 1 мы хотим получить в результате bounded rewrite.
---
## Коротко: что именно строим
Нужен не просто thread-safe registry namespace-ов, а orchestration layer с явным lifecycle.
То есть не объект вида:
- `map[string]string` + `AddNamespace()`
а объект вида:
- обнаружение namespace;
- нормализация состояния;
- единый жизненный цикл add/remove/reconcile;
- подписка компонентов на события;
- безопасный snapshot для background loops;
- backfill existing namespaces on startup;
- восстановление после restart.
Рабочее имя этой сущности: `NamespaceManager`.
---
## Какую проблему он решает
Сейчас логика размазана по нескольким слоям одновременно:
1. `NamespaceResolver` хранит registry.
2. watcher-ы executor/router/buildermgr сами решают, как регистрировать namespace.
3. components сами придумывают свой dedup.
4. часть background loops читают namespace snapshot.
5. provisioning SA/RBAC живёт как side effect watcher-а.
Из-за этого нет одного ответа на вопросы:
1. Когда namespace считается «принятым» системой?
2. Когда он считается «удалённым»?
3. Что должно происходить при restart компонента?
4. Кто отвечает за cleanup?
5. Кто отвечает за reconcile при расхождении локального и фактического состояния?
`NamespaceManager` нужен именно для того, чтобы эти вопросы получили один общий ответ.
---
## Какие свойства должны быть у новой подсистемы
### 1. Один вход для namespace lifecycle
Все namespace-ы, независимо от того, пришли они:
- из env на старте;
- из уже существующих labeled namespaces;
- из нового namespace event;
- из relabel existing namespace;
должны проходить через один и тот же pipeline.
### 2. Явный state machine
Нельзя больше жить в модели «namespace либо есть в map, либо нет». Нужны как минимум фазы:
- discovered;
- registering;
- active;
- deregistering;
- removed;
- failed.
Не обязательно все эти фазы сразу экспонировать наружу, но внутренняя модель должна понимать, на каком этапе lifecycle находится namespace.
### 3. Разделение ответственности
Нужно развести по слоям:
1. Discovery — кто узнал о namespace.
2. Registry — текущее состояние namespace в памяти процесса.
3. Reconcile — как довести локальное состояние до желаемого.
4. Subscription — как сообщить executor/router/buildermgr о событии.
5. Provisioning — отдельные side effects вроде SA/RBAC.
### 4. Thread-safe чтение и запись
Любой компонент должен иметь один безопасный способ получить:
- snapshot namespace-ов;
- текущее состояние конкретного namespace;
- stream событий.
### 5. Symmetry startup vs runtime
Если namespace был известен на старте или пришёл позже, конечный набор действий должен быть одинаковым.
Именно этот пункт был нарушен в `newdeploy`, и именно он должен стать жёстким архитектурным правилом нового дизайна.
---
## Что не должно быть в новой модели
### 1. Прямых чтений глобальной map из произвольных мест
Любой код, который напрямую читает внутреннюю структуру namespace registry, должен считаться legacy и подлежать выносу.
### 2. Глобального dedup вместо локального lifecycle
Global registry отвечает только на вопрос «namespace известен системе». Он не должен автоматически означать «каждый компонент уже подключил все свои informers».
### 3. Неявных side effects в watcher callback
Watcher должен сообщать о факте, а не выполнять пол-процесса orchestration сам по себе.
### 4. Скрытой зависимости от порядка вызовов
Сейчас уже был пойман дефект, когда второй компонент не регистрировался, потому что первый успел пометить namespace как «уже обработанный». Новая модель должна быть инвариантна к порядку subscriber-ов.
---
## Предлагаемая модель данных
Ниже не обязательно точный конечный код, но это целевая форма.
```go
type NamespacePhase string
const (
NamespacePhaseDiscovered NamespacePhase = "discovered"
NamespacePhaseRegistering NamespacePhase = "registering"
NamespacePhaseActive NamespacePhase = "active"
NamespacePhaseDeregistering NamespacePhase = "deregistering"
NamespacePhaseRemoved NamespacePhase = "removed"
NamespacePhaseFailed NamespacePhase = "failed"
)
type NamespaceRecord struct {
Name string
Source NamespaceSource
Labels map[string]string
Phase NamespacePhase
LastError string
Generation int64
UpdatedAt time.Time
RegisteredParts map[string]NamespacePartState
}
type NamespacePartState struct {
State string
LastError string
UpdatedAt time.Time
}
```
Важная идея: manager должен знать не только список namespace-ов, но и состояние регистрации по частям.
Например:
- executor.poolmgr: active
- executor.newdeploy: active
- router: active
- buildermgr.env: active
- buildermgr.pkg: failed
- provisioning.fetcher-sa: active
Это критично для reconcile. Иначе при частичном падении система знает только «namespace есть», но не знает, что именно недорегистрировано.
---
## Источники namespace-ов
Нужен явный тип источника, чтобы не смешивать namespace-ы с разным происхождением.
```go
type NamespaceSource string
const (
NamespaceSourceEnv NamespaceSource = "env"
NamespaceSourceWatcher NamespaceSource = "watcher"
NamespaceSourceBackfill NamespaceSource = "backfill"
)
```
Почему это важно:
1. Проще расследовать состояние системы.
2. Проще логировать, откуда namespace попал в менеджер.
3. Проще понять, что именно должно переживать restart и что должно исчезать при relabel/delete.
---
## Предлагаемый API NamespaceManager
Ниже не «идеальный forever API», а минимально полезный контракт.
```go
type NamespaceManager interface {
Snapshot() []string
SnapshotRecords() []NamespaceRecord
Get(name string) (NamespaceRecord, bool)
RegisterDesired(ctx context.Context, event NamespaceEvent) error
DeregisterDesired(ctx context.Context, name string, reason string) error
Subscribe(name string, subscriber NamespaceSubscriber)
Start(ctx context.Context)
}
```
И ещё важнее — не только sync API, но и события.
```go
type NamespaceEventType string
const (
NamespaceEventAdd NamespaceEventType = "add"
NamespaceEventUpdate NamespaceEventType = "update"
NamespaceEventRemove NamespaceEventType = "remove"
NamespaceEventResync NamespaceEventType = "resync"
)
type NamespaceEvent struct {
Type NamespaceEventType
Name string
Labels map[string]string
Source NamespaceSource
ObservedAt time.Time
}
type NamespaceSubscriber interface {
Name() string
OnNamespaceAdd(ctx context.Context, ns NamespaceRecord) error
OnNamespaceRemove(ctx context.Context, ns NamespaceRecord) error
OnNamespaceResync(ctx context.Context, ns NamespaceRecord) error
}
```
---
## Как должен работать startup
Это один из самых важных разделов. Сейчас именно startup/runtime symmetry остаётся центральным требованием.
### Текущий анти-pattern
Сначала что-то строится по env namespaces, потом dynamic path делает другой набор действий отдельно.
### Целевой startup
При старте процесса manager должен:
1. Собрать namespaces из env.
2. Сделать backfill всех существующих namespaces с label `fission.io/managed=true`.
3. Нормализовать список без дублей.
4. Сформировать initial desired set.
5. Пропустить весь этот set через тот же reconcile pipeline, что и поздние события.
6. Только потом считать manager готовым.
Иначе говоря:
startup — это просто массовый initial reconcile, а не отдельная логика «в обход».
---
## Как должен работать runtime add
Когда watcher видит новый namespace или relabel в `managed=true`, он не должен сам лезть во все компоненты.
Он должен только отправить event в manager:
```go
RegisterDesired(NamespaceEvent{Type: Add, Name: ns, Source: Watcher, Labels: ...})
```
Дальше manager:
1. Обновляет/создаёт `NamespaceRecord`.
2. Ставит phase `registering`.
3. По подписчикам запускает reconcile `OnNamespaceAdd`.
4. Фиксирует state каждой части.
5. Если все обязательные части успешны, переводит namespace в `active`.
6. Если часть упала, переводит в `failed` с возможностью повторной reconcile.
Это важно: add должен быть idempotent и retry-friendly.
---
## Как должен работать runtime remove
Это следующий большой пробел в текущем Layer 1.
Нужен единый remove path для двух случаев:
1. namespace удалён;
2. label `fission.io/managed=true` снят.
Пайплайн должен быть таким:
1. Watcher сообщает `remove` event.
2. Manager помечает namespace как `deregistering`.
3. Вызывает `OnNamespaceRemove` у подписчиков.
4. Каждый подписчик:
- останавливает локальные informers;
- удаляет namespace из локальных lister maps;
- очищает связанный cache state.
5. После успешного снятия подписок manager переводит namespace в `removed` или удаляет запись полностью.
Главная причина делать это централизованно:
если remove semantics будут разъезжаться по компонентам, получится новая версия текущей проблемы, только уже в lifecycle удаления.
---
## Как должен работать reconcile
Remove/add недостаточно. Нужен ещё reconcile.
Причины:
1. Компонент мог стартовать позже manager-а.
2. Подписчик мог упасть на середине регистрации namespace.
3. Restart процесса может привести к тому, что локальная память пуста, а кластерное состояние уже существует.
Поэтому manager должен уметь периодически или по событию заново прогонять namespace через subscriber-ов.
Например:
```go
OnNamespaceResync(ctx, ns)
```
Или через тот же `OnNamespaceAdd`, если он строго idempotent.
Инженерно я бы предпочёл следующее правило:
1. `OnNamespaceAdd` и `OnNamespaceResync` могут быть одной реализацией.
2. Но семантически различать их всё равно полезно для логов и метрик.
---
## Кто должен быть subscriber-ами
### 1. Executor subscriber
Внутри него можно уже вызывать внутренние add/remove/resync по типам:
- poolmgr
- newdeploy
- container
Но для manager это один subscriber уровня executor.
Почему это лучше:
1. Manager не должен знать детали каждого executor type.
2. Executor сам лучше знает, что для него является complete registration.
### 2. Router subscriber
Отвечает за:
- func informer;
- trigger informer;
- resolver informer registry;
- invalidate/rebuild path.
### 3. BuilderMgr subscriber
Но внутри него стоит сделать внутреннее разделение частей:
- env watcher part;
- pkg watcher part.
Именно потому, что на этом месте уже был пойман баг локального dedup.
### 4. Provisioning subscriber
Отдельный subscriber для:
- `fission-fetcher` SA;
- возможно builder SA;
- связанных Role/RoleBinding path.
Почему это должен быть отдельный subscriber:
сейчас provisioning встроен как side effect watcher-а, а это делает sequencing слишком хрупким и плохо наблюдаемым.
---
## Почему provisioning нужно вынести отдельно
Сейчас логика «namespace зарегистрирован» и логика «в namespace создан нужный service account + RBAC» слишком слеплены.
Это вредно по нескольким причинам:
1. Трудно диагностировать, что именно сломалось: discovery, informer wiring или RBAC provisioning.
2. Нельзя отдельно повторить provisioning без повторного полного namespace registration.
3. Нельзя нормально отслеживать частичный success.
Целевой дизайн:
- manager знает, что provisioning — это отдельная обязательная или полуобязательная часть namespace lifecycle;
- provisioning subscriber отдаёт свой статус отдельно;
- при необходимости его можно повторно reconcile без переинициализации router/executor/buildermgr.
---
## Нужен ли новый объект вместо NamespaceResolver
Да, но не обязательно удалять `NamespaceResolver` в один момент.
Реалистичная стратегия:
### Этап A
Сделать `NamespaceResolver` внутренней реализацией snapshot/compat layer.
### Этап B
Поверх него построить `NamespaceManager` как orchestration layer.
### Этап C
Постепенно вычистить прямые зависимости компонентов от `NamespaceResolver` и перевести их на manager/subscriber contract.
Почему так, а не сразу delete old resolver:
1. Слишком много мест уже используют текущие helper-ы.
2. Нужен период совместного существования старого snapshot API и нового orchestration API.
3. Иначе blast radius снова станет слишком большим.
---
## Минимальный состав внутренних методов manager-а
Ниже не внешний API, а то, что почти наверняка понадобится внутри.
```go
func (m *manager) upsertRecord(event NamespaceEvent) NamespaceRecord
func (m *manager) markPartState(ns string, subscriber string, state NamespacePartState)
func (m *manager) markPhase(ns string, phase NamespacePhase, err error)
func (m *manager) snapshotActiveNamespaces() []string
func (m *manager) emit(event internalEvent)
func (m *manager) reconcileNamespace(ctx context.Context, name string)
func (m *manager) removeNamespace(ctx context.Context, name string)
```
Причина: если manager не умеет хранить part-level state, он снова выродится в glorified map.
---
## Какой порядок вызовов нужен при add
Не просто «вызвать всех subscriber-ов подряд». Нужна осознанная последовательность.
Один из возможных вариантов:
1. Provisioning subscriber
2. BuilderMgr subscriber
3. Executor subscriber
4. Router subscriber
Но это не единственный вариант. Важно другое: порядок должен быть явным и объяснимым.
Почему provisioning логично раньше:
если namespace ещё не имеет нужного service account, часть runtime path может не подняться корректно.
Почему router можно позже:
он меньше зависит от SA provisioning, чем runtime execution path.
Но я бы не жёстко кодировал этот порядок как случайную последовательность callback-ов. Лучше иметь явно заданную subscriber order policy.
---
## Как manager должен вести себя при частичном падении
Это одна из самых важных деталей, потому что сейчас система часто мыслит бинарно: success/fail.
Нужно поведение такого типа:
1. Executor зарегистрировался успешно.
2. Router зарегистрировался успешно.
3. BuilderMgr не зарегистрировался.
4. Namespace получает phase `failed` или `active-with-errors`.
5. В record фиксируется, что именно сломалось.
6. Reconcile можно повторить только для buildermgr part.
Именно это позволит избегать режимов «namespace вроде есть, но реально не полностью обслуживается, а система этого не видит».
---
## Метрики и логирование
Без этого новый manager будет трудно отлаживать.
Нужно как минимум:
### Метрики
- число active namespaces;
- число failed namespaces;
- число reconcile attempts;
- число add/remove events;
- количество ошибок по subscriber-ам.
### Логи
На каждое важное событие должны быть логи такого класса:
- namespace discovered;
- namespace registration started;
- subscriber registration succeeded;
- subscriber registration failed;
- namespace active;
- namespace deregistering;
- namespace removed;
- resync started/completed.
Без этого следующая стадия дебага снова упрётся в разрозненные логи компонентов.
---
## Тестовая стратегия для нового этапа
Нельзя ограничиться только unit tests отдельных helper-ов.
Нужны как минимум четыре слоя проверок.
### 1. Unit tests manager state machine
- add нового namespace;
- повторный add идемпотентен;
- remove переводит в нужную фазу;
- partial failure отражается в part states.
### 2. Unit tests subscriber ordering / reconcile
- add вызывает всех нужных subscriber-ов;
- failure одного subscriber-а не портит состояние других;
- повторный resync догоняет незарегистрированную часть.
### 3. Component tests
- buildermgr add/remove;
- router add/remove;
- newdeploy add parity;
- executor resync.
### 4. End-to-end tests
- startup with existing managed namespaces;
- late namespace add;
- relabel add;
- label removal;
- namespace delete;
- process restart;
- burst onboarding.
---
## Как бы я разбил реализацию следующего этапа на коммиты
Это очень важно: не повторять ошибку большого rewrite.
### Commit A
Добавить скелет `NamespaceManager` и in-memory record model без подключения компонентов.
Цель:
- новый тип существует;
- есть unit tests state model;
- legacy path ещё не тронут.
### Commit B
Подключить discovery path: env + namespace watcher events начинают идти в manager.
Но subscribers пока можно ограничить одним compatibility subscriber.
### Commit C
Сделать provisioning отдельным subscriber-ом.
### Commit D
Перевести buildermgr на manager/subscriber contract.
Почему именно buildermgr первым:
там уже был пойман реальный dedup defect, и логика явно просит более чистый lifecycle.
### Commit E
Перевести router на manager/subscriber contract.
### Commit F
Перевести executor subscriber.
### Commit G
Добавить remove/relabel/delete lifecycle.
### Commit H
Вычистить legacy прямые обращения к resolver там, где это уже возможно.
---
## Что можно оставить совместимым на переходный период
Не всё нужно ломать сразу.
Можно временно оставить:
1. `Snapshot()` API у `NamespaceResolver` как compatibility layer.
2. Часть существующих helper-ов для informer factory creation.
3. Отдельные component-specific `AddNamespace()` методы, но вызывать их уже через manager subscriber.
Это позволит переподключать компоненты последовательно.
---
## Какие риски у самого NamespaceManager rewrite
Нужно честно фиксировать и риски новой архитектуры.
### 1. Over-centralization
Если сделать manager слишком умным, он начнёт знать внутренности каждого компонента, и получится новый монолит уже поверх старого.
Поэтому manager должен оркестрировать lifecycle, но не содержать доменную логику executor/router/buildermgr.
### 2. Deadlocks или долгие lock sections
Если state manager будет держать lock во время вызова subscriber-ов, это плохой дизайн.
Нужно правило:
- lock только на обновление внутреннего state;
- вызовы subscriber-ов делать вне глобального lock.
### 3. Excessive retries
Если reconcile не ограничить и не сделать наблюдаемым, можно получить noisy system с бесконечными повторными попытками.
### 4. Confused ownership
Если не определить, кто отвечает за remove/reconcile конкретной части, получится новая версия старой размазанной логики.
---
## Что я считаю правильным следующим шагом после этого документа
Не сразу кодить full manager.
Сначала нужен ещё один маленький подготовительный шаг:
1. Добавить новый package или файл со skeleton model `NamespaceRecord`, `NamespacePhase`, `NamespaceEvent`.
2. Покрыть его unit tests.
3. Не подключать пока к production lifecycle.
Почему:
это даст опорную модель данных, вокруг которой уже можно строить manager, не смешивая сразу storage, watchers и subscribers.
---
## Итог
Целевой `NamespaceManager` для Layer 1 — это не «один общий namespace» и не «ещё один helper над map`ой`».
Это должен быть orchestration слой с пятью обязательными свойствами:
1. единый lifecycle add/remove/resync;
2. state model с phase и part-level status;
3. подписчики-компоненты вместо хаотичных side effects;
4. symmetry startup и runtime onboarding;
5. безопасный reconcile после ошибок и restart.
Только после этого можно сказать, что Layer 1 действительно перестал быть монопользовательским Fission с набором динамических заплаток и стал многопользовательским control-plane слоем с понятным жизненным циклом.
+135
View File
@@ -0,0 +1,135 @@
# 2026-04-26 — RBAC fix для multi-tenant SA provisioning
## Симптом
`test_layer1.sh` шаг 5 падает: pod poolmgr не создаётся в динамически добавленном NS.
Event: `serviceaccount "fission-fetcher" not found`
## Путь диагностики
1. **Код есть**`EnsureNamespaceSA` добавлена в `serviceaccount.go`, вызывается из `ns_watcher.go:168`
2. **Образ задеплоен** — v8 работает, executor регистрирует NS (шаги 1-4 PASS)
3. **RBAC проверка**: `kubectl auth can-i create serviceaccounts --as=...fission-executor -n l1-test-77773`**`no`**
4. **ClusterRole `fission-executor-multi-ns`** имеет только `get/list/watch` для serviceaccounts, нет rules для `roles`/`rolebindings`
## Вывод
`setupSAAndRoleBindings` вызывается, но k8s отвечает 403 → функция тихо логирует ошибку и возвращает → SA не создаётся.
## Решение
Исправить `deploy/multitenant/rbac.yaml` — добавить ClusterRole с нужными правами + ClusterRoleBinding.
## Сделано
- Добавлен ClusterRole `fission-executor-sa-provisioner` с `create/update/patch` для `serviceaccounts`, `roles`, `rolebindings` (namespace-scoped через ClusterRole)
- Добавлен ClusterRoleBinding к SA `fission-executor` в NS `fission`
- `kubectl apply` — применено
- Верификация: `kubectl auth can-i create serviceaccounts/roles/rolebindings`**`yes/yes/yes`** ✅
## Результат после RBAC fix (2026-04-26)
Применено, RBAC проверка: `yes/yes/yes`
SA `fission-fetcher` создаётся в новом NS за 15 сек ✅
Тест `test_layer1.sh` всё равно 4/5 FAIL ❌
---
## Новая проблема — executor timeout при вызове функции
### Симптом
Шаг 5 (`вызываем функцию`): `HTTP 500 — error sending request to function`
Лог router:
```
function service entry timeout (60.000000)s exceeded
error posting to getting service for function: POST http://executor.fission/v2/getServiceForFunction
giving up after 4 attempt(s): context deadline exceeded
function: {namespace: l1-test-78841, name: hello}
```
### Что происходит
Router обращается к executor `/v2/getServiceForFunction`, executor не отвечает в течение 60 сек.
SA `fission-fetcher` уже есть (RBAC fix помог). Но poolmgr pod так и не запустился или executor не может создать service entry.
### Что нужно проверить
1. Есть ли pod poolmgr в NS `l1-test-78841`?
2. Если pod не создаётся — события в NS (`kubectl get events -n l1-test-78841`)
3. Если pod есть — логи executor (`kubectl logs -n fission deploy/executor`)
4. Может ли executor вообще видеть функции в динамически добавленном NS?
### Гипотезы
A. **Executor не видит функцию** — NS зарегистрирован в NSWatcher, но executor informer не получил Function объект → `getServiceForFunction` не знает о функции → timeout.
B. **poolmgr pod не стартует** — новая RBAC проблема или другой ресурс отсутствует.
C. **Executor видит функцию, но pool не готов** — cold start > 60 сек (маловероятно для Python hello).
---
## Обновление анализа — найден реальный RBAC root cause
### Подтверждённые факты
- Pool pod в новом NS создаётся и выходит в `Running`.
- `readyPod controller started` есть в логах executor.
- Ошибка возникает раньше/ниже: при `EnsureNamespaceSA` executor создаёт `ServiceAccount`, но не может создать `Role` полностью.
### Точный лог ошибки
```
error while creating role for sa fission-fetcher in namespace diag-ns-82702
roles.rbac.authorization.k8s.io ... is forbidden: user "system:serviceaccount:fission:fission-executor"
is attempting to grant RBAC permissions not currently held:
{APIGroups:[""], Resources:["events"], Verbs:["create"]}
```
Также перед этим:
```
localsubjectaccessreviews.authorization.k8s.io is forbidden
User "system:serviceaccount:fission:fission-executor" cannot create resource
"localsubjectaccessreviews"
```
### Вывод
Предыдущий RBAC fix был неполным.
Для динамического SA provisioning executor нужны не только:
- `serviceaccounts.create/update/patch`
- `roles.create/update/patch`
- `rolebindings.create/update/patch`
Но и ещё:
- `events.create` — иначе Kubernetes запрещает executor создавать Role, которая выдаёт `events.create` fetcher-у.
- `authorization.k8s.io/localsubjectaccessreviews.create` — иначе `checkPermission()` не может проверить текущие права SA.
### Исправление
Расширить `deploy/multitenant/rbac.yaml` для `fission-executor-sa-provisioner`:
- core `events`: `create`
- `authorization.k8s.io` `localsubjectaccessreviews`: `create`
После этого нужно:
1. `kubectl apply -f deploy/multitenant/rbac.yaml`
2. Создать новый test NS
3. Убедиться, что `Role` и `RoleBinding` для `fission-fetcher` создаются
4. Повторить `test_layer1.sh`
---
## Следующий найденный blocker — router RBAC
После полного executor RBAC fix `test_layer1.sh` изменил симптом:
- раньше шаг 5 падал с `500` и timeout на `executor /v2/getServiceForFunction`
- теперь шаг 5 падает с постоянным `404`
Лог router:
```
Failed to watch err="failed to list *v1.Namespace: namespaces is forbidden:
User \"system:serviceaccount:fission:fission-router\" cannot list resource
\"namespaces\" in API group \"\" at the cluster scope"
```
### Вывод
Executor-path уже починен, но router NSWatcher не работает, потому что у SA
`fission-router` нет cluster-scope прав `list/watch` на `namespaces`.
### Исправление
Добавить в `deploy/multitenant/rbac.yaml` ещё один набор ресурсов:
- `ClusterRole/fission-router-ns-watcher`
- `ClusterRoleBinding/fission-router-ns-watcher`
С правами:
- core `namespaces`: `list`, `watch`
@@ -0,0 +1,115 @@
# 2026-04-26 - Почему Sonnet 4.6 мог застрять на Layer1 и в чём он может быть сильнее
## Зачем этот документ
После успешного завершения кейса возник мета-вопрос:
- почему другая модель могла не дойти до рабочего решения
- в чём она всё же может быть объективно лучше
Документ нужен как заметка о процессе расследования, а не о самом кодовом fix.
## Почему Sonnet 4.6 мог не дожать именно этот кейс
### Кейс был каскадным
Здесь не было одного простого корня.
Последовательность была такой:
1. отсутствует `fission-fetcher`
2. потом выясняется недостаток прав на `Role/RoleBinding`
3. потом выясняется, что не хватает ещё и делегируемых permission-ов (`events.create`)
4. потом выясняется, что не хватает `localsubjectaccessreviews.create`
5. потом executor-path становится рабочим, но router-path всё ещё сломан
6. затем обнаруживается отсутствие namespace watch/list у `fission-router`
Модель, которая мыслит в режиме "нашёл корень -> исправил -> готово", на таком сценарии часто останавливается слишком рано.
### Симптомы менялись и маскировали прогресс
Промежуточные симптомы были:
- `serviceaccount not found`
- `500 timeout`
- `404`
- `200`
Это классический случай, где изменение симптома означает не провал, а смену активного bottleneck.
Если интерпретировать это неправильно, расследование начинает метаться.
### Нужно было понимать RBAC delegation, а не только RBAC access
Ключевая тонкость кейса:
- executor создаёт `Role` для fetcher
- эта `Role` выдаёт `events.create`
- Kubernetes запрещает создавать `Role`, делегирующую permission, которого нет у самого вызывающего субъекта
Следовательно надо было догадаться, что executor обязан получить `events.create`, хотя сам код падал не на "events usage", а на создании `Role`.
Это не самый очевидный вывод без жёсткой опоры на лог и знание RBAC semantics.
### Нужен был именно инструментальный debugging loop
Решение появилось не после одной сильной гипотезы, а после цикла:
1. найти симптом
2. проверить конкретное право
3. воспроизвести в новом namespace
4. подтвердить создание реальных объектов
5. перезапустить e2e test
6. перейти к следующему симптому
Без этого модель легко даёт хорошее объяснение, но не доводит задачу до зелёного результата.
## Что Sonnet 4.6 может делать лучше меня
### 1. Быстрый широкий синтез
Sonnet часто хорошо работает на старте, когда нужно быстро:
- разложить проблему по подсистемам
- набросать несколько гипотез
- предложить архитектурные альтернативы
- собрать большой черновик текста
### 2. High-level проектирование и brainstorming
На задачах вида:
- "какую архитектуру выбрать"
- "какие trade-off у подходов"
- "как разложить крупный рефактор"
он может давать очень сильный первый проход.
### 3. Большие гладкие черновики
Для первых версий:
- design-doc
- proposal
- API draft
- architecture summary
Sonnet нередко удобен именно скоростью и связностью первой версии.
## Что оказалось важнее в этом кейсе
В этом расследовании решающим было не качество первого explanation, а жёсткость процесса:
- не верить первому найденному root cause
- валидировать каждый шаг через cluster state
- считать fix завершённым только после `PASS=5/5`
- вносить изменения в код/манифесты, а не лечить кластер временными patch-командами
## Итоговая формулировка
Корректно говорить так:
- Sonnet может быть сильнее в широком синтезе, brainstorming, архитектурных черновиках и быстрых первых гипотезах
- в этом конкретном кейсе я оказался сильнее в последовательной инструментальной диагностике, удержании нескольких меняющихся симптомов и доведении расследования до рабочего e2e результата
То есть различие проявилось не в "умнее/глупее", а в типе задачи.
@@ -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 тега
Executable
BIN
View File
Binary file not shown.
+132
View File
@@ -0,0 +1,132 @@
import re
# ── envwatcher.go ─────────────────────────────────────────────────────────────
with open("/home/naeel/terra/fission-src/pkg/buildermgr/envwatcher.go") as f:
src = f.read()
# добавляем genInformer import если нет
if "genInformer" not in src:
src = src.replace(
'"github.com/fission/fission/pkg/generated/clientset/versioned"',
'"github.com/fission/fission/pkg/generated/clientset/versioned"\n\t'
'genInformer "github.com/fission/fission/pkg/generated/informers/externalversions"',
1
)
# добавляем fmt если нет
if '"fmt"' not in src:
src = src.replace('"context"', '"context"\n\t"fmt"', 1)
addon = r'''
// AddNamespace dynamically registers a new namespace in environmentWatcher.
// Creates a per-NS Environment informer. Safe to call repeatedly — deduplicates via nsResolver.
func (envw *environmentWatcher) AddNamespace(ctx context.Context, ns string, mgr manager.Interface) {
if !envw.nsResolver.AddNamespace(ns) {
return // already registered
}
envw.logger.Info("buildermgr.envWatcher.AddNamespace: setting up informer", zap.String("namespace", ns))
factory := genInformer.NewFilteredSharedInformerFactory(envw.fissionClient, 30*time.Minute, ns, nil)
envInf := factory.Core().V1().Environments().Informer()
_, err := envInf.AddEventHandler(k8sCache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
envObj := obj.(*fv1.Environment)
envw.AddUpdateBuilder(ctx, envObj)
},
UpdateFunc: func(oldObj interface{}, newObj interface{}) {
oldEnvObj := oldObj.(*fv1.Environment)
newEnvObj := newObj.(*fv1.Environment)
if oldEnvObj.ResourceVersion == newEnvObj.ResourceVersion {
return
}
envw.AddUpdateBuilder(ctx, newEnvObj)
},
DeleteFunc: func(obj interface{}) {
envObj, ok := obj.(*fv1.Environment)
if !ok {
return
}
envw.deleteBuilder(ctx, envObj)
},
})
if err != nil {
envw.logger.Error("buildermgr.envWatcher.AddNamespace: add handler failed",
zap.String("namespace", ns), zap.Error(fmt.Errorf("%w", err)))
return
}
envw.envWatchInformer[ns] = envInf
mgr.AddInformers(ctx, map[string]k8sCache.SharedIndexInformer{ns: envInf})
envw.logger.Info("buildermgr.envWatcher.AddNamespace: done", zap.String("namespace", ns))
}
'''
with open("/home/naeel/terra/fission-src/pkg/buildermgr/envwatcher.go", "w") as f:
f.write(src + addon)
print("envwatcher.go: done")
# ── pkgwatcher.go ─────────────────────────────────────────────────────────────
with open("/home/naeel/terra/fission-src/pkg/buildermgr/pkgwatcher.go") as f:
src = f.read()
# добавляем genInformer import если нет
if "genInformer" not in src:
src = src.replace(
'"github.com/fission/fission/pkg/generated/clientset/versioned"',
'"github.com/fission/fission/pkg/generated/clientset/versioned"\n\t'
'genInformer "github.com/fission/fission/pkg/generated/informers/externalversions"',
1
)
# добавляем fmt если нет
if '"fmt"' not in src:
src = src.replace('"context"', '"context"\n\t"fmt"', 1)
# Добавляем k8sInformers если нет
if "k8sInformers" not in src:
src = src.replace(
'"k8s.io/client-go/kubernetes"',
'"k8s.io/client-go/kubernetes"\n\tk8sInformers "k8s.io/client-go/informers"',
1
)
addon2 = r'''
// AddNamespace dynamically registers a new namespace in packageWatcher.
// Creates per-NS Package and Pod informers. Safe to call repeatedly.
func (pkgw *packageWatcher) AddNamespace(ctx context.Context, ns string, mgr manager.Interface) {
if !pkgw.nsResolver.AddNamespace(ns) {
return // already registered
}
pkgw.logger.Info("buildermgr.pkgWatcher.AddNamespace: setting up informers", zap.String("namespace", ns))
// Package informer
fissionFactory := genInformer.NewFilteredSharedInformerFactory(pkgw.fissionClient, 30*time.Minute, ns, nil)
pkgInf := fissionFactory.Core().V1().Packages().Informer()
_, err := pkgInf.AddEventHandler(pkgw.packageInformerHandler(ctx))
if err != nil {
pkgw.logger.Error("buildermgr.pkgWatcher.AddNamespace: pkg handler failed",
zap.String("namespace", ns), zap.Error(fmt.Errorf("%w", err)))
return
}
// Pod informer for build logs
podFactory := k8sInformers.NewSharedInformerFactoryWithOptions(pkgw.k8sClient, 30*time.Minute,
k8sInformers.WithNamespace(ns))
podInf := podFactory.Core().V1().Pods().Informer()
pkgw.pkgInformer[ns] = pkgInf
pkgw.podInformer[ns] = podInf
mgr.AddInformers(ctx, map[string]k8sCache.SharedIndexInformer{
ns + "/pkg": pkgInf,
ns + "/pod": podInf,
})
pkgw.logger.Info("buildermgr.pkgWatcher.AddNamespace: done", zap.String("namespace", ns))
}
'''
with open("/home/naeel/terra/fission-src/pkg/buildermgr/pkgwatcher.go", "w") as f:
f.write(src + addon2)
print("pkgwatcher.go: done")
+4
View File
@@ -74,5 +74,9 @@ func Start(ctx context.Context, clientGen crd.ClientGeneratorInterface, logger *
if err != nil {
return err
}
// Multi-tenant: watch namespaces labeled fission.io/managed=true
StartNSWatcher(ctx, logger, kubernetesClient, envWatcher, pkgWatcher, mgr)
return nil
}
+63 -1
View File
@@ -39,6 +39,7 @@ import (
"github.com/fission/fission/pkg/executor/util"
fetcherConfig "github.com/fission/fission/pkg/fetcher/config"
"github.com/fission/fission/pkg/generated/clientset/versioned"
genInformer "github.com/fission/fission/pkg/generated/informers/externalversions"
"github.com/fission/fission/pkg/utils"
"github.com/fission/fission/pkg/utils/manager"
)
@@ -75,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
}
)
@@ -110,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
@@ -498,3 +501,62 @@ func (envw *environmentWatcher) createBuilderDeployment(ctx context.Context, env
return deployment, nil
}
// AddNamespace dynamically registers a new namespace in environmentWatcher.
// Creates a per-NS Environment informer. Safe to call repeatedly — deduplicates via env informer map.
func (envw *environmentWatcher) AddNamespace(ctx context.Context, ns string, mgr manager.Interface) {
if _, exists := envw.envWatchInformer[ns]; exists {
return // already registered
}
envw.logger.Info("buildermgr.envWatcher.AddNamespace: setting up informer", zap.String("namespace", ns))
factory := genInformer.NewFilteredSharedInformerFactory(envw.fissionClient, 30*time.Minute, ns, nil)
envInf := factory.Core().V1().Environments().Informer()
_, err := envInf.AddEventHandler(k8sCache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
envObj := obj.(*fv1.Environment)
envw.AddUpdateBuilder(ctx, envObj)
},
UpdateFunc: func(oldObj interface{}, newObj interface{}) {
oldEnvObj := oldObj.(*fv1.Environment)
newEnvObj := newObj.(*fv1.Environment)
if oldEnvObj.ResourceVersion == newEnvObj.ResourceVersion {
return
}
envw.AddUpdateBuilder(ctx, newEnvObj)
},
DeleteFunc: func(obj interface{}) {
envObj, ok := obj.(*fv1.Environment)
if !ok {
return
}
envw.DeleteBuilder(ctx, envObj)
},
})
if err != nil {
envw.logger.Error("buildermgr.envWatcher.AddNamespace: add handler failed",
zap.String("namespace", ns), zap.Error(fmt.Errorf("%w", err)))
return
}
envw.envWatchInformer[ns] = envInf
mgr.AddInformers(ctx, map[string]k8sCache.SharedIndexInformer{ns: envInf})
// 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))
}
+68
View File
@@ -0,0 +1,68 @@
package buildermgr
import (
"context"
"github.com/fission/fission/pkg/utils"
"github.com/fission/fission/pkg/utils/manager"
)
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",
AddFunc: func(ctx context.Context, record utils.NamespaceRecord) error {
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
},
}
}
func registerBuilderNamespace(ctx context.Context, namespace string, envw builderEnvNamespaceAdder, pkgw builderPkgNamespaceAdder, mgr manager.Interface) {
if namespace == "" {
return
}
utils.DefaultNSResolver().AddNamespace(namespace)
if envw != nil {
envw.AddNamespace(ctx, namespace, mgr)
}
if pkgw != nil {
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)
}
}
@@ -0,0 +1,52 @@
package buildermgr
import (
"context"
"testing"
"github.com/fission/fission/pkg/utils"
"github.com/fission/fission/pkg/utils/manager"
)
type fakeBuilderEnvNamespaceAdder struct {
lastNamespace string
calls int
}
func (f *fakeBuilderEnvNamespaceAdder) AddNamespace(ctx context.Context, ns string, mgr manager.Interface) {
f.lastNamespace = ns
f.calls++
}
type fakeBuilderPkgNamespaceAdder struct {
lastNamespace string
calls int
}
func (f *fakeBuilderPkgNamespaceAdder) AddNamespace(ctx context.Context, ns string, mgr manager.Interface) {
f.lastNamespace = ns
f.calls++
}
func TestNewNamespaceSubscriberAddAndResync(t *testing.T) {
envw := &fakeBuilderEnvNamespaceAdder{}
pkgw := &fakeBuilderPkgNamespaceAdder{}
subscriber := NewNamespaceSubscriber(envw, pkgw, nil)
record := utils.NamespaceRecord{Name: "tenant-builder-a"}
if subscriber.Name() != "buildermgr" {
t.Fatalf("expected buildermgr subscriber name")
}
if err := subscriber.OnNamespaceAdd(context.Background(), record); err != nil {
t.Fatalf("expected add to succeed: %v", err)
}
if err := subscriber.OnNamespaceResync(context.Background(), record); err != nil {
t.Fatalf("expected resync to succeed: %v", err)
}
if envw.calls != 2 || pkgw.calls != 2 {
t.Fatalf("expected both watchers to be called for add and resync")
}
if envw.lastNamespace != "tenant-builder-a" || pkgw.lastNamespace != "tenant-builder-a" {
t.Fatalf("expected namespace to be forwarded to both watchers")
}
}
+34
View File
@@ -0,0 +1,34 @@
// Package buildermgr — NSWatcher for multi-tenant mode.
//
// Listens for Namespaces labeled fission.io/managed=true and calls
// AddNamespace on envWatcher and packageWatcher so they pick up
// Environments and Packages in new tenant namespaces without a restart.
package buildermgr
import (
"context"
"go.uber.org/zap"
"k8s.io/client-go/kubernetes"
"github.com/fission/fission/pkg/utils"
"github.com/fission/fission/pkg/utils/manager"
)
// StartNSWatcher watches for Namespaces labeled fission.io/managed=true
// and immediately registers per-NS informers in envWatcher and pkgWatcher.
func StartNSWatcher(
ctx context.Context,
logger *zap.Logger,
kubeClient kubernetes.Interface,
envw *environmentWatcher,
pkgw *packageWatcher,
mgr manager.Interface,
) {
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))
}
}
+57
View File
@@ -25,6 +25,7 @@ import (
apiv1 "k8s.io/api/core/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
k8sInformers "k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes"
k8sCache "k8s.io/client-go/tools/cache"
@@ -32,6 +33,7 @@ import (
"github.com/fission/fission/pkg/cache"
"github.com/fission/fission/pkg/crd"
"github.com/fission/fission/pkg/generated/clientset/versioned"
genInformer "github.com/fission/fission/pkg/generated/informers/externalversions"
"github.com/fission/fission/pkg/utils"
"github.com/fission/fission/pkg/utils/manager"
"github.com/fission/fission/pkg/utils/metrics"
@@ -47,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
}
)
@@ -62,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
}
@@ -329,3 +334,55 @@ func setInitialBuildStatus(ctx context.Context, fissionClient versioned.Interfac
// TODO: use UpdateStatus to update status
return fissionClient.CoreV1().Packages(pkg.Namespace).Update(ctx, pkg, metav1.UpdateOptions{})
}
// AddNamespace dynamically registers a new namespace in packageWatcher.
// Creates per-NS Package and Pod informers. Safe to call repeatedly — deduplicates via local informer maps.
func (pkgw *packageWatcher) AddNamespace(ctx context.Context, ns string, mgr manager.Interface) {
if _, exists := pkgw.pkgInformer[ns]; exists {
return // already registered
}
pkgw.logger.Info("buildermgr.pkgWatcher.AddNamespace: setting up informers", zap.String("namespace", ns))
// Package informer
fissionFactory := genInformer.NewFilteredSharedInformerFactory(pkgw.fissionClient, 30*time.Minute, ns, nil)
pkgInf := fissionFactory.Core().V1().Packages().Informer()
_, err := pkgInf.AddEventHandler(pkgw.packageInformerHandler(ctx))
if err != nil {
pkgw.logger.Error("buildermgr.pkgWatcher.AddNamespace: pkg handler failed",
zap.String("namespace", ns), zap.Error(fmt.Errorf("%w", err)))
return
}
// Pod informer for build logs
podFactory := k8sInformers.NewSharedInformerFactoryWithOptions(pkgw.k8sClient, 30*time.Minute,
k8sInformers.WithNamespace(ns))
podInf := podFactory.Core().V1().Pods().Informer()
pkgw.pkgInformer[ns] = pkgInf
pkgw.podInformer[ns] = podInf
mgr.AddInformers(ctx, map[string]k8sCache.SharedIndexInformer{
ns + "/pkg": pkgInf,
ns + "/pod": podInf,
})
// 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))
}
+13 -1
View File
@@ -37,6 +37,7 @@ import (
"github.com/fission/fission/pkg/executor/executortype/newdeploy"
"github.com/fission/fission/pkg/executor/executortype/poolmgr"
"github.com/fission/fission/pkg/executor/fscache"
"github.com/fission/fission/pkg/executor/multitenant"
"github.com/fission/fission/pkg/executor/util"
fetcherConfig "github.com/fission/fission/pkg/fetcher/config"
"github.com/fission/fission/pkg/generated/clientset/versioned"
@@ -293,7 +294,7 @@ func StartExecutor(ctx context.Context, clientGen crd.ClientGeneratorInterface,
logger.Info("Starting executor", zap.String("instanceID", executorInstanceID))
finformerFactory := make(map[string]genInformer.SharedInformerFactory, 0)
for _, ns := range utils.DefaultNSResolver().FissionResourceNS {
for _, ns := range utils.DefaultNSResolver().Snapshot() {
finformerFactory[ns] = genInformer.NewFilteredSharedInformerFactory(fissionClient, time.Minute*30, ns, nil)
}
@@ -346,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{}
@@ -399,6 +405,12 @@ func StartExecutor(ctx context.Context, clientGen crd.ClientGeneratorInterface,
utils.CreateMissingPermissionForSA(ctx, kubernetesClient, logger)
// Start multi-tenant Namespace watcher.
// Detects Namespaces labeled fission.io/managed=true and registers them in all
// executor types without a pod restart. Backward-compatible with FISSION_RESOURCE_NAMESPACES.
// See: pkg/executor/multitenant/ns_watcher.go
multitenant.StartNSWatcher(ctx, logger, kubernetesClient, executorTypes, mgr)
mgr.Add(ctx, func(ctx context.Context) {
metrics.ServeMetrics(ctx, "executor", logger, mgr)
})
@@ -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(),
}
@@ -292,7 +295,7 @@ func (caaf *Container) RefreshFuncPods(ctx context.Context, logger *zap.Logger,
func (caaf *Container) AdoptExistingResources(ctx context.Context) {
wg := &sync.WaitGroup{}
for _, namepsace := range utils.DefaultNSResolver().FissionResourceNS {
for _, namepsace := range utils.DefaultNSResolver().Snapshot() {
fnList, err := caaf.fissionClient.CoreV1().Functions(namepsace).List(ctx, metav1.ListOptions{})
if err != nil {
caaf.logger.Error("error getting function list", zap.Error(err))
@@ -792,3 +795,77 @@ func getDeploymentObj(kubeobjs []apiv1.ObjectReference) *apiv1.ObjectReference {
func (caaf *Container) DumpDebugInfo(ctx context.Context) error {
return nil
}
// AddNamespace dynamically registers a new namespace in the container executor without a restart.
// Sets up deployment and service listers so the executor can manage container functions in the new NS.
func (caaf *Container) AddNamespace(ctx context.Context, ns string, mgr manager.Interface) error {
if ns == "" {
return nil
}
// Use container-specific dedup: check if deplLister is already set up for this NS.
// Do NOT use DefaultNSResolver().AddNamespace() — that is a global single-call guard
// shared by all executor types and is now called once in multitenant.registerNamespace.
if _, ok := caaf.deplLister[ns]; ok {
return nil // already registered
}
caaf.logger.Info("AddNamespace: setting up informers for new namespace (container)", zap.String("namespace", ns))
finformer := genInformer.NewFilteredSharedInformerFactory(caaf.fissionClient, 30*time.Minute, ns, nil)
executorLabel, err := utils.GetInformerLabelByExecutor(fv1.ExecutorTypeContainer)
if err != nil {
return fmt.Errorf("AddNamespace %s (container): get executor label: %w", ns, err)
}
cnmInformer := k8sInformers.NewSharedInformerFactoryWithOptions(
caaf.kubernetesClient,
30*time.Minute,
k8sInformers.WithTweakListOptions(func(opts *metav1.ListOptions) {
opts.LabelSelector = executorLabel.String()
}),
k8sInformers.WithNamespace(ns),
)
caaf.deplLister[ns] = cnmInformer.Apps().V1().Deployments().Lister()
caaf.deplListerSynced[ns] = cnmInformer.Apps().V1().Deployments().Informer().HasSynced
caaf.svcLister[ns] = cnmInformer.Core().V1().Services().Lister()
caaf.svcListerSynced[ns] = cnmInformer.Core().V1().Services().Informer().HasSynced
_, err = finformer.Core().V1().Functions().Informer().AddEventHandler(caaf.FuncInformerHandler(ctx))
if err != nil {
return fmt.Errorf("AddNamespace %s (container): add function handler: %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)
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
}
+10
View File
@@ -69,4 +69,14 @@ type ExecutorType interface {
// CleanupOldExecutorObjects cleans up resources created by old executor instances
CleanupOldExecutorObjects(context.Context)
// AddNamespace dynamically registers a new user namespace so the executor
// 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(),
}
@@ -312,7 +315,7 @@ func (deploy *NewDeploy) RefreshFuncPods(ctx context.Context, logger *zap.Logger
func (deploy *NewDeploy) AdoptExistingResources(ctx context.Context) {
wg := &sync.WaitGroup{}
for _, namepsace := range utils.DefaultNSResolver().FissionResourceNS {
for _, namepsace := range utils.DefaultNSResolver().Snapshot() {
fnList, err := deploy.fissionClient.CoreV1().Functions(namepsace).List(ctx, metav1.ListOptions{})
if err != nil {
deploy.logger.Error("error getting function list", zap.Error(err))
@@ -782,7 +785,7 @@ func (deploy *NewDeploy) idleObjectReaper(ctx context.Context) {
func (deploy *NewDeploy) doIdleObjectReaper(ctx context.Context) {
envList := make(map[k8sTypes.UID]struct{})
for _, namespace := range utils.DefaultNSResolver().FissionResourceNS {
for _, namespace := range utils.DefaultNSResolver().Snapshot() {
envs, err := deploy.fissionClient.CoreV1().Environments(namespace).List(ctx, metav1.ListOptions{})
if err != nil {
deploy.logger.Fatal("failed to get environment list", zap.Error(err), zap.String("namespace", namespace))
@@ -898,3 +901,87 @@ func (deploy *NewDeploy) scaleDeployment(ctx context.Context, deplNS string, dep
func (deploy *NewDeploy) DumpDebugInfo(ctx context.Context) error {
return nil
}
// AddNamespace dynamically registers a new namespace in the newdeploy executor without a restart.
// Sets up deployment and service listers so the executor can manage functions in the new NS.
// Safe to call repeatedly — uses per-executor deplLister for deduplication.
func (deploy *NewDeploy) AddNamespace(ctx context.Context, ns string, mgr manager.Interface) error {
if ns == "" {
return nil
}
// Use newdeploy-specific dedup: check if deplLister is already set up for this NS.
// Do NOT use DefaultNSResolver().AddNamespace() — that is a global single-call guard
// shared by all executor types and is now called once in multitenant.registerNamespace.
if _, ok := deploy.deplLister[ns]; ok {
return nil // already registered
}
deploy.logger.Info("AddNamespace: setting up informers for new namespace (newdeploy)", zap.String("namespace", ns))
// Fission CRD informer factory for the new NS.
finformer := genInformer.NewFilteredSharedInformerFactory(deploy.fissionClient, 30*time.Minute, ns, nil)
// K8s deployment+service informer factory filtered by newdeploy executor label.
executorLabel, err := utils.GetInformerLabelByExecutor(fv1.ExecutorTypeNewdeploy)
if err != nil {
return fmt.Errorf("AddNamespace %s (newdeploy): get executor label: %w", ns, err)
}
ndmInformer := k8sInformers.NewSharedInformerFactoryWithOptions(
deploy.kubernetesClient,
30*time.Minute,
k8sInformers.WithTweakListOptions(func(opts *metav1.ListOptions) {
opts.LabelSelector = executorLabel.String()
}),
k8sInformers.WithNamespace(ns),
)
// Register deployment and service listers — same as done at startup in MakeNewDeploy.
deploy.deplLister[ns] = ndmInformer.Apps().V1().Deployments().Lister()
deploy.deplListerSynced[ns] = ndmInformer.Apps().V1().Deployments().Informer().HasSynced
deploy.svcLister[ns] = ndmInformer.Core().V1().Services().Lister()
deploy.svcListerSynced[ns] = ndmInformer.Core().V1().Services().Informer().HasSynced
// Register function event handler so this NS's functions are adopted.
_, err = finformer.Core().V1().Functions().Informer().AddEventHandler(deploy.FunctionEventHandlers(ctx))
if err != nil {
return fmt.Errorf("AddNamespace %s (newdeploy): add function handler: %w", ns, err)
}
_, err = finformer.Core().V1().Environments().Informer().AddEventHandler(deploy.EnvEventHandlers(ctx))
if err != nil {
return fmt.Errorf("AddNamespace %s (newdeploy): add environment handler: %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)
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
}
+95 -4
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()
@@ -357,7 +362,7 @@ func (gpm *GenericPoolManager) AdoptExistingResources(ctx context.Context) {
envMap := make(map[string]fv1.Environment)
wg := &sync.WaitGroup{}
for _, namespace := range utils.DefaultNSResolver().FissionResourceNS {
for _, namespace := range utils.DefaultNSResolver().Snapshot() {
envs, err := gpm.fissionClient.CoreV1().Environments(namespace).List(ctx, metav1.ListOptions{})
if err != nil {
gpm.logger.Error("error getting environment list", zap.Error(err))
@@ -389,7 +394,7 @@ func (gpm *GenericPoolManager) AdoptExistingResources(ctx context.Context) {
fv1.EXECUTOR_TYPE: string(fv1.ExecutorTypePoolmgr),
}
for _, namespace := range utils.DefaultNSResolver().FissionResourceNS {
for _, namespace := range utils.DefaultNSResolver().Snapshot() {
podList, err := gpm.kubernetesClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
LabelSelector: labels.Set(l).AsSelector().String(),
})
@@ -624,7 +629,7 @@ func (gpm *GenericPoolManager) idleObjectReaper(ctx context.Context) {
func (gpm *GenericPoolManager) doIdleObjectReaper(ctx context.Context) {
envList := make(map[k8sTypes.UID]struct{})
for _, namespace := range utils.DefaultNSResolver().FissionResourceNS {
for _, namespace := range utils.DefaultNSResolver().Snapshot() {
envs, err := gpm.fissionClient.CoreV1().Environments(namespace).List(ctx, metav1.ListOptions{})
if err != nil {
gpm.logger.Error("failed to get environment list", zap.Error(err), zap.String("namespace", namespace))
@@ -637,7 +642,7 @@ func (gpm *GenericPoolManager) doIdleObjectReaper(ctx context.Context) {
}
fnList := make(map[k8sTypes.UID]fv1.Function)
for _, namespace := range utils.DefaultNSResolver().FissionResourceNS {
for _, namespace := range utils.DefaultNSResolver().Snapshot() {
fns, err := gpm.fissionClient.CoreV1().Functions(namespace).List(ctx, metav1.ListOptions{})
if err != nil {
gpm.logger.Error("failed to get environment list", zap.Error(err), zap.String("namespace", namespace))
@@ -784,3 +789,89 @@ func (gpm *GenericPoolManager) NoActiveConnectionEventChecker(ctx context.Contex
func (gpm *GenericPoolManager) DumpDebugInfo(ctx context.Context) error {
return gpm.fsCache.DumpDebugInfo(ctx)
}
// AddNamespace dynamically registers a new namespace in the poolmgr executor without a restart.
// Called by watchManagedNamespaces when a Namespace with label fission.io/managed=true appears.
//
// What it does:
// 1. Adds the namespace to the global NamespaceResolver (thread-safe, deduplicates)
// 2. Creates a Fission CRD informer factory for the NS (watches Environments, Functions, Packages)
// 3. Creates a K8s pod informer factory filtered by poolmgr executor label
// 4. Registers the informers with PoolPodController (envLister, podLister, RS watcher)
// 5. Starts both informer factories
//
// If the namespace was already registered, returns nil immediately (no-op).
func (gpm *GenericPoolManager) AddNamespace(ctx context.Context, ns string, mgr manager.Interface) error {
if ns == "" {
// Empty NS means "scan all" — for poolmgr this is a no-op; discovery is done by the caller.
return nil
}
// Use poolmgr-specific dedup: check if envLister is already set up for this NS.
// Do NOT use DefaultNSResolver().AddNamespace() — that is a global single-call guard
// shared by all executor types and is now called once in multitenant.registerNamespace.
if _, ok := gpm.poolPodC.envLister[ns]; ok {
return nil
}
gpm.logger.Info("AddNamespace: setting up informers for new namespace", zap.String("namespace", ns))
// Fission CRD informer factory — watches Environments, Functions, Packages in this NS.
finformer := genInformer.NewFilteredSharedInformerFactory(gpm.fissionClient, 30*time.Minute, ns, nil)
// K8s pod/RS informer factory filtered by poolmgr executor label.
// Same label used at startup in GetInformerFactoryByExecutor.
executorLabel, err := utils.GetInformerLabelByExecutor(fv1.ExecutorTypePoolmgr)
if err != nil {
return fmt.Errorf("AddNamespace %s: get executor label: %w", ns, err)
}
gpmInformer := k8sInformers.NewSharedInformerFactoryWithOptions(
gpm.kubernetesClient,
30*time.Minute,
k8sInformers.WithTweakListOptions(func(opts *metav1.ListOptions) {
opts.LabelSelector = executorLabel.String()
}),
k8sInformers.WithNamespace(ns),
)
// Register the new informers with PoolPodController.
if err := gpm.poolPodC.AddNamespaceInformers(ctx, ns, finformer, gpmInformer); err != nil {
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(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
}
@@ -485,3 +485,57 @@ func (p *PoolPodController) spCleanupPodQueueProcessFunc(ctx context.Context) bo
p.spCleanupPodQueue.Forget(key)
return false
}
// AddNamespaceInformers registers informers for a newly-added namespace in PoolPodController.
// Called from GenericPoolManager.AddNamespace after the informer factories are created.
//
// Sets up:
// - Environment lister and synced func (for pool creation on env events)
// - Pod lister and synced func (for specialized pod tracking)
// - ReplicaSet event handler (for RS scale-down pod cleanup)
func (p *PoolPodController) AddNamespaceInformers(
ctx context.Context,
ns string,
finformer genInformer.SharedInformerFactory,
gpmInformer k8sInformers.SharedInformerFactory,
) error {
// Environment informer — triggers pool creation/deletion when envs change in this NS.
_, err := finformer.Core().V1().Environments().Informer().AddEventHandler(k8sCache.ResourceEventHandlerFuncs{
AddFunc: p.enqueueEnvAdd,
UpdateFunc: p.enqueueEnvUpdate,
DeleteFunc: p.enqueueEnvDelete,
})
if err != nil {
return fmt.Errorf("AddNamespaceInformers %s: add env handler: %w", ns, err)
}
p.envLister[ns] = finformer.Core().V1().Environments().Lister()
p.envListerSynced[ns] = finformer.Core().V1().Environments().Informer().HasSynced
// Pod lister — used by processRS to find specialized pods in this NS.
p.podLister[ns] = gpmInformer.Core().V1().Pods().Lister()
p.podListerSynced[ns] = gpmInformer.Core().V1().Pods().Informer().HasSynced
// ReplicaSet informer — triggers cleanup of specialized pods when RS scales to 0.
_, err = gpmInformer.Apps().V1().ReplicaSets().Informer().AddEventHandler(k8sCache.ResourceEventHandlerFuncs{
AddFunc: p.handleRSAdd,
UpdateFunc: p.handleRSUpdate,
DeleteFunc: p.handleRSDelete,
})
if err != nil {
return fmt.Errorf("AddNamespaceInformers %s: add RS handler: %w", ns, err)
}
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))
}
@@ -0,0 +1,36 @@
package multitenant
import (
"context"
"go.uber.org/zap"
"k8s.io/client-go/kubernetes"
fv1 "github.com/fission/fission/pkg/apis/core/v1"
"github.com/fission/fission/pkg/executor/executortype"
"github.com/fission/fission/pkg/utils"
"github.com/fission/fission/pkg/utils/manager"
)
func NewNamespaceSubscriber(
logger *zap.Logger,
kubernetesClient kubernetes.Interface,
executorTypes map[fv1.ExecutorType]executortype.ExecutorType,
mgr manager.Interface,
) utils.NamespaceSubscriber {
return utils.NamespaceSubscriberFuncs{
SubscriberName: "executor",
AddFunc: func(ctx context.Context, record utils.NamespaceRecord) error {
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 {
// 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)
},
}
}
@@ -0,0 +1,37 @@
package multitenant
import (
"context"
"testing"
"go.uber.org/zap"
k8sfake "k8s.io/client-go/kubernetes/fake"
fv1 "github.com/fission/fission/pkg/apis/core/v1"
"github.com/fission/fission/pkg/executor/executortype"
"github.com/fission/fission/pkg/utils"
)
func TestNewNamespaceSubscriberAddAndResync(t *testing.T) {
poolmgr := &fakeExecutorType{}
subscriber := NewNamespaceSubscriber(zap.NewNop(), k8sfake.NewSimpleClientset(), map[fv1.ExecutorType]executortype.ExecutorType{
fv1.ExecutorTypePoolmgr: poolmgr,
}, nil)
record := utils.NamespaceRecord{Name: "tenant-executor-b"}
if subscriber.Name() != "executor" {
t.Fatalf("expected executor subscriber name")
}
if err := subscriber.OnNamespaceAdd(context.Background(), record); err != nil {
t.Fatalf("expected add to succeed: %v", err)
}
if err := subscriber.OnNamespaceResync(context.Background(), record); err != nil {
t.Fatalf("expected resync to succeed: %v", err)
}
if poolmgr.addCalls != 2 {
t.Fatalf("expected executor type to receive add and resync calls")
}
if poolmgr.lastNamespace != "tenant-executor-b" {
t.Fatalf("expected namespace to be forwarded to executor type")
}
}
+201
View File
@@ -0,0 +1,201 @@
// Package multitenant provides utilities for running Fission in a multi-tenant
// Kubernetes environment where user namespaces are created dynamically at runtime.
//
// # The Problem
//
// In a standard Fission installation, all resource namespaces must be enumerated in the
// FISSION_RESOURCE_NAMESPACES environment variable before the process starts. Adding a
// new user namespace requires patching that env var and triggering a rolling restart of
// every Fission component (executor, router, buildermgr, etc.) — causing ~30 seconds of
// downtime per new tenant.
//
// At scale this becomes a severe operational problem: with hundreds of new tenants being
// created continuously, the executor is in a permanent rolling restart loop, causing
// cascading failures for all existing users.
//
// # The Solution
//
// This package implements hot namespace registration without pod restarts.
// The mechanism is intentionally simple and decoupled from any specific platform:
//
// 1. A platform (a cloud console, a CI/CD system, an operator) creates a Kubernetes
// Namespace and sets the label:
// fission.io/managed=true
//
// 2. StartNSWatcher registers a Kubernetes Namespace Informer that receives an event
// the moment a labeled Namespace is created or updated — no polling, no delay.
//
// 3. On the AddFunc / UpdateFunc callback NSWatcher calls AddNamespace on every
// registered executor type (poolmgr, newdeploy, container). Each type creates
// per-NS informer factories, pod listers, and event handlers — live, without restart.
//
// 4. NamespaceResolver.AddNamespace deduplicates — calling AddNamespace on an already-
// registered namespace is always a safe no-op.
//
// # Backward Compatibility
//
// The FISSION_RESOURCE_NAMESPACES environment variable continues to work as before.
// Namespaces listed there are registered at startup and do not require the label.
// This package adds on top of the existing mechanism — it does not replace it.
//
// # Required RBAC
//
// The fission-executor ServiceAccount must be granted permission to list and watch
// Namespaces at the cluster scope. Apply the manifest at:
//
// deploy/multitenant/rbac.yaml
//
// # Integration Contract
//
// The entire integration contract for external platforms is a single label on a Namespace:
//
// apiVersion: v1
// kind: Namespace
// metadata:
// name: tenant-abc123
// labels:
// fission.io/managed: "true"
//
// No other coupling to Fission internals is required or expected.
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"
"github.com/fission/fission/pkg/executor/executortype"
"github.com/fission/fission/pkg/utils"
"github.com/fission/fission/pkg/utils/manager"
)
// StartNSWatcher registers a Kubernetes Namespace Informer that reacts immediately
// when a Namespace with label fission.io/managed=true is created or relabeled.
//
// Unlike a polling approach, this uses the standard k8s Watch mechanism — the executor
// receives the event within milliseconds of the Namespace appearing, with zero wasted
// API calls between events.
//
// The informer is managed via mgr and shuts down cleanly when ctx is cancelled.
func StartNSWatcher(
ctx context.Context,
logger *zap.Logger,
kubernetesClient kubernetes.Interface,
executorTypes map[fv1.ExecutorType]executortype.ExecutorType,
mgr manager.Interface,
) {
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))
}
}
// registerNamespace calls AddNamespace on every executor type for the given namespace.
// The global NamespaceResolver is updated here — once, before any executor type is called.
// 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,
kubernetesClient kubernetes.Interface,
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.
// 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(
ctx context.Context,
logger *zap.Logger,
ns string,
executorTypes map[fv1.ExecutorType]executortype.ExecutorType,
mgr manager.Interface,
) error {
var joinErr error
for _, et := range executorTypes {
if err := et.AddNamespace(ctx, ns, mgr); err != nil {
logger.Error("multitenant.NSWatcher: AddNamespace failed",
zap.String("namespace", ns),
zap.Error(err),
)
joinErr = errors.Join(joinErr, err)
}
}
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)))
}
@@ -0,0 +1,79 @@
package multitenant
import (
"context"
"errors"
"testing"
"go.uber.org/zap"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
fv1 "github.com/fission/fission/pkg/apis/core/v1"
"github.com/fission/fission/pkg/executor/executortype"
"github.com/fission/fission/pkg/executor/fscache"
"github.com/fission/fission/pkg/utils/manager"
)
type fakeExecutorType struct {
addCalls int
lastNamespace string
addErr error
}
func (f *fakeExecutorType) Run(context.Context, manager.Interface) {}
func (f *fakeExecutorType) GetTypeName(context.Context) fv1.ExecutorType {
return fv1.ExecutorTypePoolmgr
}
func (f *fakeExecutorType) GetFuncSvc(context.Context, *fv1.Function) (*fscache.FuncSvc, error) {
return nil, nil
}
func (f *fakeExecutorType) GetFuncSvcFromCache(context.Context, *fv1.Function) (*fscache.FuncSvc, error) {
return nil, nil
}
func (f *fakeExecutorType) DumpDebugInfo(context.Context) error { return nil }
func (f *fakeExecutorType) DeleteFuncSvcFromCache(context.Context, *fscache.FuncSvc) {}
func (f *fakeExecutorType) TapService(context.Context, string) error { return nil }
func (f *fakeExecutorType) UnTapService(context.Context, *metav1.ObjectMeta, string) {}
func (f *fakeExecutorType) MarkSpecializationFailure(context.Context, *metav1.ObjectMeta) {}
func (f *fakeExecutorType) IsValid(context.Context, *fscache.FuncSvc) bool { return true }
func (f *fakeExecutorType) RefreshFuncPods(context.Context, *zap.Logger, fv1.Function) error {
return nil
}
func (f *fakeExecutorType) AdoptExistingResources(context.Context) {}
func (f *fakeExecutorType) CleanupOldExecutorObjects(context.Context) {}
func (f *fakeExecutorType) AddNamespace(ctx context.Context, ns string, mgr manager.Interface) error {
f.addCalls++
f.lastNamespace = ns
return f.addErr
}
func (f *fakeExecutorType) RemoveNamespace(ctx context.Context, ns string) error { return nil }
var _ executortype.ExecutorType = (*fakeExecutorType)(nil)
func TestRegisterExecutorTypes(t *testing.T) {
poolmgr := &fakeExecutorType{}
container := &fakeExecutorType{}
err := registerExecutorTypes(context.Background(), zap.NewNop(), "tenant-executor-a", map[fv1.ExecutorType]executortype.ExecutorType{
fv1.ExecutorTypePoolmgr: poolmgr,
fv1.ExecutorTypeContainer: container,
}, nil)
if err != nil {
t.Fatalf("expected registration success: %v", err)
}
if poolmgr.addCalls != 1 || container.addCalls != 1 {
t.Fatalf("expected all executor types to receive AddNamespace")
}
}
func TestRegisterExecutorTypesAggregatesErrors(t *testing.T) {
err := registerExecutorTypes(context.Background(), zap.NewNop(), "tenant-executor-a", map[fv1.ExecutorType]executortype.ExecutorType{
fv1.ExecutorTypePoolmgr: &fakeExecutorType{addErr: errors.New("poolmgr failed")},
fv1.ExecutorTypeContainer: &fakeExecutorType{},
}, nil)
if err == nil {
t.Fatalf("expected aggregated error")
}
if err.Error() != "poolmgr failed" {
t.Fatalf("unexpected error: %v", err)
}
}
+11
View File
@@ -18,6 +18,7 @@ package router
import (
"fmt"
"sync"
"time"
"go.uber.org/zap"
@@ -35,6 +36,7 @@ type (
// FunctionReference -> function metadata
refCache *cache.Cache[namespacedTriggerReference, resolveResult]
funcInformer map[string]k8sCache.SharedIndexInformer
mu sync.RWMutex
logger *zap.Logger
// store k8sCache.Store
}
@@ -120,12 +122,21 @@ func (frr *functionReferenceResolver) resolve(trigger fv1.HTTPTrigger) (*resolve
}
func (frr *functionReferenceResolver) getInformerByNamespace(namespace string) (k8sCache.SharedIndexInformer, error) {
frr.mu.RLock()
defer frr.mu.RUnlock()
if informer, ok := frr.funcInformer[namespace]; ok {
return informer, nil
}
return nil, fmt.Errorf("informer for namespace %s not found", namespace)
}
func (frr *functionReferenceResolver) addInformer(namespace string, informer k8sCache.SharedIndexInformer) {
frr.mu.Lock()
defer frr.mu.Unlock()
frr.funcInformer[namespace] = informer
}
// resolveByName simply looks up function by name in a namespace.
func (frr *functionReferenceResolver) resolveByName(namespace, name string) (*resolveResult, error) {
// get function from cache
+152 -13
View File
@@ -18,8 +18,10 @@ package router
import (
"context"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/bep/debounce"
@@ -35,6 +37,7 @@ import (
eclient "github.com/fission/fission/pkg/executor/client"
config "github.com/fission/fission/pkg/featureconfig"
"github.com/fission/fission/pkg/generated/clientset/versioned"
genInformer "github.com/fission/fission/pkg/generated/informers/externalversions"
"github.com/fission/fission/pkg/info"
"github.com/fission/fission/pkg/throttler"
"github.com/fission/fission/pkg/utils"
@@ -47,15 +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
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
@@ -80,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)
@@ -308,7 +315,7 @@ func (ts *HTTPTriggerSet) updateTriggerStatusFailed(ht *fv1.HTTPTrigger, err err
}
func (ts *HTTPTriggerSet) addTriggerHandlers() error {
for _, triggerInformer := range ts.triggerInformer {
for _, triggerInformer := range ts.snapshotTriggerInformers() {
_, err := triggerInformer.AddEventHandler(k8sCache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
trigger := obj.(*fv1.HTTPTrigger)
@@ -340,7 +347,7 @@ func (ts *HTTPTriggerSet) addTriggerHandlers() error {
}
func (ts *HTTPTriggerSet) addFunctionHandlers() error {
for _, funcInformer := range ts.funcInformer {
for _, funcInformer := range ts.snapshotFuncInformers() {
_, err := funcInformer.AddEventHandler(k8sCache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
@@ -387,6 +394,28 @@ func (ts *HTTPTriggerSet) syncTriggers() {
})
}
func (ts *HTTPTriggerSet) snapshotTriggerInformers() []k8sCache.SharedIndexInformer {
ts.informerMu.RLock()
defer ts.informerMu.RUnlock()
informers := make([]k8sCache.SharedIndexInformer, 0, len(ts.triggerInformer))
for _, informer := range ts.triggerInformer {
informers = append(informers, informer)
}
return informers
}
func (ts *HTTPTriggerSet) snapshotFuncInformers() []k8sCache.SharedIndexInformer {
ts.informerMu.RLock()
defer ts.informerMu.RUnlock()
informers := make([]k8sCache.SharedIndexInformer, 0, len(ts.funcInformer))
for _, informer := range ts.funcInformer {
informers = append(informers, informer)
}
return informers
}
func (ts *HTTPTriggerSet) updateRouter(ctx context.Context) {
for {
select {
@@ -396,7 +425,7 @@ func (ts *HTTPTriggerSet) updateRouter(ctx context.Context) {
}
// get triggers
alltriggers := make([]fv1.HTTPTrigger, 0)
for _, triggerInformer := range ts.triggerInformer {
for _, triggerInformer := range ts.snapshotTriggerInformers() {
latestTriggers := triggerInformer.GetStore().List()
for _, t := range latestTriggers {
alltriggers = append(alltriggers, *t.(*fv1.HTTPTrigger))
@@ -407,7 +436,7 @@ func (ts *HTTPTriggerSet) updateRouter(ctx context.Context) {
// get functions
allfunctions := make([]fv1.Function, 0)
functionTimeout := make(map[types.UID]int, 0)
for _, funcInformer := range ts.funcInformer {
for _, funcInformer := range ts.snapshotFuncInformers() {
latestFunctions := funcInformer.GetStore().List()
for _, f := range latestFunctions {
fn := *f.(*fv1.Function)
@@ -426,3 +455,113 @@ func (ts *HTTPTriggerSet) updateRouter(ctx context.Context) {
ts.mutableRouter.updateRouter(router)
}
}
// AddNamespace dynamically registers a new namespace in the router without a restart.
// Creates per-NS informers for HTTPTriggers and Functions, wires up event handlers,
// and triggers a router rebuild. Safe to call repeatedly — deduplicates via NSResolver.
func (ts *HTTPTriggerSet) AddNamespace(ctx context.Context, ns string, mgr manager.Interface) error {
if !utils.DefaultNSResolver().AddNamespace(ns) {
return nil // already registered
}
ts.logger.Info("router.AddNamespace: setting up informers", zap.String("namespace", ns))
factory := genInformer.NewFilteredSharedInformerFactory(ts.fissionClient, 30*time.Minute, ns, nil)
triggerInf := factory.Core().V1().HTTPTriggers().Informer()
funcInf := factory.Core().V1().Functions().Informer()
_, err := triggerInf.AddEventHandler(k8sCache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
trigger := obj.(*fv1.HTTPTrigger)
go createIngress(context.Background(), ts.logger, trigger, ts.kubeClient)
ts.syncTriggers()
},
DeleteFunc: func(obj interface{}) {
ts.syncTriggers()
trigger := obj.(*fv1.HTTPTrigger)
go deleteIngress(context.Background(), ts.logger, trigger, ts.kubeClient)
},
UpdateFunc: func(oldObj interface{}, newObj interface{}) {
oldTrigger := oldObj.(*fv1.HTTPTrigger)
newTrigger := newObj.(*fv1.HTTPTrigger)
if oldTrigger.ObjectMeta.ResourceVersion == newTrigger.ObjectMeta.ResourceVersion {
return
}
go updateIngress(context.Background(), ts.logger, oldTrigger, newTrigger, ts.kubeClient)
ts.syncTriggers()
},
})
if err != nil {
return fmt.Errorf("router.AddNamespace %s: trigger handler: %w", ns, err)
}
_, err = funcInf.AddEventHandler(k8sCache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) { ts.syncTriggers() },
DeleteFunc: func(obj interface{}) { ts.syncTriggers() },
UpdateFunc: func(oldObj interface{}, newObj interface{}) {
oldFn := oldObj.(*fv1.Function)
fn := newObj.(*fv1.Function)
if oldFn.ObjectMeta.ResourceVersion == fn.ObjectMeta.ResourceVersion {
return
}
for key, rr := range ts.resolver.copy() {
if key.namespace == fn.ObjectMeta.Namespace &&
rr.functionMap[fn.ObjectMeta.Name] != nil &&
rr.functionMap[fn.ObjectMeta.Name].ObjectMeta.ResourceVersion != fn.ObjectMeta.ResourceVersion {
ts.logger.Debug("invalidating resolver cache")
_ = ts.resolver.delete(key.namespace, key.triggerName, key.triggerResourceVersion)
break
}
}
ts.syncTriggers()
},
})
if err != nil {
return fmt.Errorf("router.AddNamespace %s: func handler: %w", ns, err)
}
ts.informerMu.Lock()
ts.triggerInformer[ns] = triggerInf
ts.funcInformer[ns] = funcInf
ts.informerMu.Unlock()
ts.resolver.addInformer(ns, funcInf)
mgr.AddInformers(ctx, map[string]k8sCache.SharedIndexInformer{
ns + "/trigger": triggerInf,
ns + "/func": funcInf,
})
// 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(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))
}
+41
View File
@@ -0,0 +1,41 @@
package router
import (
"context"
"github.com/fission/fission/pkg/utils"
"github.com/fission/fission/pkg/utils/manager"
)
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)
},
}
}
func registerRouterNamespace(ctx context.Context, namespace string, ts routerNamespaceAdder, mgr manager.Interface) error {
if namespace == "" || ts == nil {
return nil
}
return ts.AddNamespace(ctx, namespace, mgr)
}
+52
View File
@@ -0,0 +1,52 @@
package router
import (
"context"
"errors"
"testing"
"github.com/fission/fission/pkg/utils"
"github.com/fission/fission/pkg/utils/manager"
)
type fakeRouterNamespaceAdder struct {
lastNamespace string
calls int
err error
}
func (f *fakeRouterNamespaceAdder) AddNamespace(ctx context.Context, ns string, mgr manager.Interface) error {
f.lastNamespace = ns
f.calls++
return f.err
}
func TestNewNamespaceSubscriberAddAndResync(t *testing.T) {
adder := &fakeRouterNamespaceAdder{}
subscriber := NewNamespaceSubscriber(adder, nil)
record := utils.NamespaceRecord{Name: "tenant-router-a"}
if subscriber.Name() != "router" {
t.Fatalf("expected router subscriber name")
}
if err := subscriber.OnNamespaceAdd(context.Background(), record); err != nil {
t.Fatalf("expected add to succeed: %v", err)
}
if err := subscriber.OnNamespaceResync(context.Background(), record); err != nil {
t.Fatalf("expected resync to succeed: %v", err)
}
if adder.calls != 2 {
t.Fatalf("expected adder to be called for add and resync")
}
if adder.lastNamespace != "tenant-router-a" {
t.Fatalf("expected namespace to be forwarded")
}
}
func TestRegisterRouterNamespacePropagatesError(t *testing.T) {
adder := &fakeRouterNamespaceAdder{err: errors.New("add failed")}
err := registerRouterNamespace(context.Background(), "tenant-router-a", adder, nil)
if err == nil || err.Error() != "add failed" {
t.Fatalf("expected router add error to be propagated, got %v", err)
}
}
+34
View File
@@ -0,0 +1,34 @@
// Package router — NSWatcher for multi-tenant mode.
//
// Listens for Namespaces labeled fission.io/managed=true and calls
// HTTPTriggerSet.AddNamespace so the router picks up HTTPTriggers and
// Functions in tenant namespaces without a restart.
package router
import (
"context"
"go.uber.org/zap"
"k8s.io/client-go/kubernetes"
"github.com/fission/fission/pkg/utils"
"github.com/fission/fission/pkg/utils/manager"
)
// StartNSWatcher registers a Kubernetes Namespace Informer for the router.
// Whenever a Namespace with label fission.io/managed=true appears (or is relabeled),
// the router immediately subscribes to HTTPTriggers and Functions in that namespace.
func StartNSWatcher(
ctx context.Context,
logger *zap.Logger,
kubeClient kubernetes.Interface,
ts *HTTPTriggerSet,
mgr manager.Interface,
) {
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))
}
}

Some files were not shown because too many files have changed in this diff Show More