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/...
175 lines
6.6 KiB
Go
175 lines
6.6 KiB
Go
// 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"
|
|
"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
|
|
}
|