diff --git a/README.md b/README.md index 6eb764933..99e3760c0 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ radar | `--kubeconfig` | `~/.kube/config` | Path to kubeconfig file | | `--kubeconfig-dir` | | Comma-separated directories containing kubeconfig files | | `--namespace` | (all) | Initial namespace filter (supports multi-select in the UI; also used as RBAC fallback for namespace-scoped users) | +| `--namespaces` | (all) | Initial namespace filters as a comma-separated list, e.g. `--namespaces ns1,ns2,ns3`. Use this when your identity can list resources in specific namespaces but cannot list namespaces cluster-wide. | | `--namespace-scope` | `false` | Pin namespaced informer caches to a **single** namespace for large clusters (scoping to multiple namespaces is not supported yet). Requires `--namespace`, a kubeconfig context namespace, or a saved local single-namespace pick. Local mode can rebuild the cache when switching namespaces; auth/cloud mode locks the shared cache to the startup namespace. | | `--port` | `9280` | Server port | | `--no-browser` | `false` | Don't auto-open browser | diff --git a/cmd/desktop/main.go b/cmd/desktop/main.go index be50e626e..3a5f4be26 100644 --- a/cmd/desktop/main.go +++ b/cmd/desktop/main.go @@ -34,6 +34,7 @@ func main() { kubeconfig := flag.String("kubeconfig", fileCfg.Kubeconfig, "Path to kubeconfig file (default: ~/.kube/config)") kubeconfigDir := flag.String("kubeconfig-dir", fileCfg.KubeconfigDirsFlag(), "Comma-separated directories containing kubeconfig files (mutually exclusive with --kubeconfig)") namespace := flag.String("namespace", fileCfg.Namespace, "Initial namespace filter (empty = all namespaces)") + namespaces := flag.String("namespaces", fileCfg.NamespacesFlag(), "Initial namespace filters as a comma-separated list (e.g. ns1,ns2,ns3). Use this when you can list resources in specific namespaces but cannot list namespaces cluster-wide.") showVersion := flag.Bool("version", false, "Show version and exit") historyLimit := flag.Int("history-limit", fileCfg.HistoryLimitOr(10000), "Maximum number of events to retain in timeline") debugEvents := flag.Bool("debug-events", false, "Enable verbose event debugging") @@ -85,6 +86,16 @@ func main() { log.Printf("ERROR: --kubeconfig and --kubeconfig-dir are mutually exclusive") os.Exit(1) } + namespaceFlagSet := false + namespacesFlagSet := false + flag.Visit(func(f *flag.Flag) { + switch f.Name { + case "namespace": + namespaceFlagSet = true + case "namespaces": + namespacesFlagSet = true + } + }) timelineMaxSizeBytes, err := config.ParseByteSize(*timelineMaxSize) if err != nil { log.Printf("ERROR: invalid --timeline-max-size %q: %v", *timelineMaxSize, err) @@ -95,11 +106,17 @@ func main() { log.Printf("ERROR: invalid Prometheus header configuration: %v", err) os.Exit(1) } + resolvedNamespace, resolvedNamespaces, err := app.ResolveNamespaceSelection(*namespace, *namespaces, namespaceFlagSet, namespacesFlagSet) + if err != nil { + log.Printf("ERROR: %v", err) + os.Exit(1) + } cfg := app.AppConfig{ Kubeconfig: *kubeconfig, KubeconfigDirs: app.ParseKubeconfigDirs(*kubeconfigDir), - Namespace: *namespace, + Namespace: resolvedNamespace, + Namespaces: resolvedNamespaces, Port: fileCfg.PortOr(0), // Configured port, or random to avoid conflicts with CLI DevMode: false, HistoryLimit: *historyLimit, @@ -182,7 +199,7 @@ func main() { WindowStartState: options.Maximised, AssetServer: &assetserver.Options{ - Handler: NewRedirectHandler(srv.ActualAddr(), cfg.Namespace), + Handler: NewRedirectHandler(srv.ActualAddr(), cfg.Namespace, cfg.Namespaces), }, Menu: createMenu(desktopApp, version), diff --git a/cmd/desktop/proxy.go b/cmd/desktop/proxy.go index 4f3f367a6..1da4ca49d 100644 --- a/cmd/desktop/proxy.go +++ b/cmd/desktop/proxy.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "net/url" + "strings" ) // RedirectHandler serves a minimal HTML page that redirects the Wails webview @@ -11,17 +12,20 @@ import ( // real localhost URL — all fetch, SSE, and WebSocket work identically to // browser mode. type RedirectHandler struct { - serverAddr string // e.g. "localhost:54321" - namespace string // initial namespace filter (empty = all) + serverAddr string // e.g. "localhost:54321" + namespace string // initial namespace filter (empty = all) + namespaces []string // initial namespace filters from --namespaces } -func NewRedirectHandler(serverAddr, namespace string) *RedirectHandler { - return &RedirectHandler{serverAddr: serverAddr, namespace: namespace} +func NewRedirectHandler(serverAddr, namespace string, namespaces []string) *RedirectHandler { + return &RedirectHandler{serverAddr: serverAddr, namespace: namespace, namespaces: append([]string(nil), namespaces...)} } func (h *RedirectHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { target := "http://" + h.serverAddr - if h.namespace != "" { + if len(h.namespaces) > 0 { + target += "?namespaces=" + url.QueryEscape(strings.Join(h.namespaces, ",")) + } else if h.namespace != "" { target += "?namespace=" + url.QueryEscape(h.namespace) } diff --git a/cmd/explorer/main.go b/cmd/explorer/main.go index f4258527d..cf1c86cac 100644 --- a/cmd/explorer/main.go +++ b/cmd/explorer/main.go @@ -8,6 +8,7 @@ import ( "log" "maps" "net" + neturl "net/url" "os" "os/signal" "sort" @@ -49,6 +50,7 @@ func main() { kubeconfig := flag.String("kubeconfig", fileCfg.Kubeconfig, "Path to kubeconfig file (default: ~/.kube/config)") kubeconfigDir := flag.String("kubeconfig-dir", fileCfg.KubeconfigDirsFlag(), "Comma-separated directories containing kubeconfig files (mutually exclusive with --kubeconfig)") namespace := flag.String("namespace", fileCfg.Namespace, "Initial namespace filter (empty = all namespaces)") + namespaces := flag.String("namespaces", fileCfg.NamespacesFlag(), "Initial namespace filters as a comma-separated list (e.g. ns1,ns2,ns3). Use this when you can list resources in specific namespaces but cannot list namespaces cluster-wide.") port := flag.Int("port", fileCfg.PortOr(9280), "Server port") noBrowser := flag.Bool("no-browser", fileCfg.NoBrowser, "Don't auto-open browser") browser := flag.String("browser", fileCfg.Browser, "Browser to use when opening the UI (default: OS default browser; macOS app names supported)") @@ -176,9 +178,16 @@ func main() { log.Fatalf("Invalid --timeline-max-size %q: %v", *timelineMaxSize, err) } noMCPFlagSet := false + namespaceFlagSet := false + namespacesFlagSet := false flag.Visit(func(f *flag.Flag) { - if f.Name == "no-mcp" { + switch f.Name { + case "no-mcp": noMCPFlagSet = true + case "namespace": + namespaceFlagSet = true + case "namespaces": + namespacesFlagSet = true } }) if *mcpCatalogOnly && noMCPFlagSet && *noMCP { @@ -191,6 +200,17 @@ func main() { if err != nil { log.Fatalf("Invalid Prometheus header configuration: %v", err) } + resolvedNamespace, resolvedNamespaces, err := app.ResolveNamespaceSelection(*namespace, *namespaces, namespaceFlagSet, namespacesFlagSet) + if err != nil { + log.Fatalf("%v", err) + } + if *namespaceScope && len(resolvedNamespaces) > 1 { + log.Fatalf("--namespace-scope supports a single namespace; use --namespace instead of --namespaces with multiple values") + } + if *namespaceScope && len(resolvedNamespaces) == 1 { + resolvedNamespace = resolvedNamespaces[0] + resolvedNamespaces = nil + } mcpEnabled := !*noMCP if *mcpCatalogOnly || *mcpCatalogStdio { mcpEnabled = true @@ -199,7 +219,8 @@ func main() { cfg := app.AppConfig{ Kubeconfig: *kubeconfig, KubeconfigDirs: app.ParseKubeconfigDirs(*kubeconfigDir), - Namespace: *namespace, + Namespace: resolvedNamespace, + Namespaces: resolvedNamespaces, Port: *port, NoBrowser: *noBrowser, Browser: *browser, @@ -302,11 +323,13 @@ func main() { // Open browser — server is confirmed ready to accept connections if !cfg.NoBrowser { - url := fmt.Sprintf("http://localhost:%d", cfg.Port) - if cfg.Namespace != "" { - url += fmt.Sprintf("?namespace=%s", cfg.Namespace) + targetURL := fmt.Sprintf("http://localhost:%d", cfg.Port) + if len(cfg.Namespaces) > 0 { + targetURL += "?namespaces=" + neturl.QueryEscape(strings.Join(cfg.Namespaces, ",")) + } else if cfg.Namespace != "" { + targetURL += "?namespace=" + neturl.QueryEscape(cfg.Namespace) } - go app.OpenBrowser(url, cfg.Browser) + go app.OpenBrowser(targetURL, cfg.Browser) } // Now initialize cluster connection and caches (browser will see progress via SSE) diff --git a/docs/configuration.md b/docs/configuration.md index f370d061c..3299790e7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -15,6 +15,7 @@ Persistent defaults for CLI flags. CLI flags always override these values. Manag "kubeconfig": "", "kubeconfigDirs": [], "namespace": "", + "namespaces": [], "port": 9280, "noBrowser": false, "browser": "", @@ -36,6 +37,7 @@ All fields are optional — omitted fields use built-in defaults. | `kubeconfig` | Path to kubeconfig file (same as `--kubeconfig`) | | `kubeconfigDirs` | Directories containing kubeconfig files (same as `--kubeconfig-dir`) | | `namespace` | Initial namespace filter | +| `namespaces` | Initial namespace filters as a list (same as `--namespaces ns1,ns2,ns3`) | | `port` | Server port (default 9280) | | `noBrowser` | Don't auto-open browser | | `browser` | Browser for automatic launch (same as `--browser`; on macOS, app names like `Google Chrome` are supported) | @@ -124,6 +126,14 @@ The header has a namespace picker on the right. Pick a single namespace to focus The pick is a per-user view filter — it doesn't change anything for other users sharing the same Radar instance. Locally, your pick is remembered per kubeconfig context across restarts. In shared (auth-enabled) deployments the pick lives for the session. +If your account can list resources inside several namespaces but cannot list namespaces cluster-wide, start Radar with an explicit list: + +```bash +kubectl radar --namespaces ns1,ns2,ns3 +``` + +Radar uses that list as the initial picker selection and as the RBAC fallback candidate set. The picker can then switch between those namespaces or keep several selected at once. + When Radar starts with `--namespace-scope`, the picker controls the process-wide cache scope instead of just a view filter. Namespaced informer caches are pinned to one namespace while cluster-scoped resources remain cluster-wide. Local/no-auth sessions can switch the scoped namespace, which rebuilds the cache in place. Auth-enabled and Radar Cloud sessions lock the picker to the startup namespace so one user cannot reshape the shared backend cache for everyone. **Single namespace only.** `--namespace-scope` pins the cache to exactly one namespace; scoping to several namespaces at once is not supported yet. Passing more than one (e.g. `--namespace=a,b`) fails at startup with a clear error rather than silently caching nothing. When scoped, the namespace picker becomes single-select, and a switch re-points the whole cache to the new namespace rather than adding to it. diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index e09618252..af12386e9 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -32,6 +32,7 @@ type AppConfig struct { Kubeconfig string KubeconfigDirs []string Namespace string + Namespaces []string Port int NoBrowser bool Browser string @@ -82,7 +83,9 @@ func InitializeK8s(cfg AppConfig) error { return fmt.Errorf("failed to initialize K8s client: %w", err) } - if cfg.Namespace != "" { + if len(cfg.Namespaces) > 0 { + k8s.SetFallbackNamespaces(cfg.Namespaces) + } else if cfg.Namespace != "" { k8s.SetFallbackNamespace(cfg.Namespace) } configureNamespaceScopePreferenceResolver(cfg) @@ -229,6 +232,7 @@ func CreateServer(cfg AppConfig) *server.Server { Kubeconfig: cfg.Kubeconfig, KubeconfigDirs: cfg.KubeconfigDirs, Namespace: cfg.Namespace, + Namespaces: cfg.Namespaces, Port: cfg.Port, NoBrowser: cfg.NoBrowser, Browser: cfg.Browser, @@ -244,11 +248,12 @@ func CreateServer(cfg AppConfig) *server.Server { } serverCfg := server.Config{ - Port: cfg.Port, - DevMode: cfg.DevMode, - StaticFS: static.FS, - StaticRoot: "dist", - EffectiveConfig: effectiveCfg, + Port: cfg.Port, + DevMode: cfg.DevMode, + StaticFS: static.FS, + StaticRoot: "dist", + EffectiveConfig: effectiveCfg, + ConfiguredNamespaces: cfg.Namespaces, DiagConfig: &server.DiagConfig{ Port: cfg.Port, DevMode: cfg.DevMode, @@ -559,3 +564,44 @@ func ParseKubeconfigDirs(dirs string) []string { } return result } + +// ParseNamespaces splits a comma-separated namespace string into a de-duplicated +// slice. Empty items are ignored so flags like "--namespaces a,,b" behave like +// kubectl's comma-separated lists instead of creating an empty namespace pick. +func ParseNamespaces(namespaces string) []string { + if namespaces == "" { + return nil + } + seen := map[string]struct{}{} + var result []string + for ns := range strings.SplitSeq(namespaces, ",") { + ns = strings.TrimSpace(ns) + if ns == "" { + continue + } + if _, ok := seen[ns]; ok { + continue + } + seen[ns] = struct{}{} + result = append(result, ns) + } + return result +} + +// ResolveNamespaceSelection applies CLI/config precedence for --namespace and +// --namespaces. Explicit flags win over config defaults; setting both flags +// explicitly is ambiguous and returns an error. +func ResolveNamespaceSelection(namespace, namespaces string, namespaceSet, namespacesSet bool) (string, []string, error) { + namespace = strings.TrimSpace(namespace) + parsedNamespaces := ParseNamespaces(namespaces) + if namespaceSet && namespacesSet && namespace != "" && len(parsedNamespaces) > 0 { + return "", nil, fmt.Errorf("--namespace and --namespaces are mutually exclusive") + } + if len(parsedNamespaces) > 0 && (namespacesSet || !namespaceSet) { + return "", parsedNamespaces, nil + } + if namespace == "" { + return "", nil, nil + } + return namespace, nil, nil +} diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index 27cf6c3bf..878ec9120 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -33,6 +33,45 @@ func TestValidateNamespaceScopeTarget(t *testing.T) { } } +func TestParseNamespaces(t *testing.T) { + got := ParseNamespaces(" team-a,team-b,,team-a , team-c ") + want := []string{"team-a", "team-b", "team-c"} + if len(got) != len(want) { + t.Fatalf("ParseNamespaces length = %d, want %d (%v)", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("ParseNamespaces[%d] = %q, want %q (all=%v)", i, got[i], want[i], got) + } + } +} + +func TestResolveNamespaceSelection(t *testing.T) { + t.Run("namespaces default wins over namespace config", func(t *testing.T) { + ns, nss, err := ResolveNamespaceSelection("legacy", "team-a,team-b", false, false) + if err != nil { + t.Fatalf("ResolveNamespaceSelection: %v", err) + } + if ns != "" || len(nss) != 2 || nss[0] != "team-a" || nss[1] != "team-b" { + t.Fatalf("got namespace=%q namespaces=%v, want namespaces team-a/team-b", ns, nss) + } + }) + t.Run("explicit namespace overrides namespaces config", func(t *testing.T) { + ns, nss, err := ResolveNamespaceSelection("prod", "team-a,team-b", true, false) + if err != nil { + t.Fatalf("ResolveNamespaceSelection: %v", err) + } + if ns != "prod" || len(nss) != 0 { + t.Fatalf("got namespace=%q namespaces=%v, want prod only", ns, nss) + } + }) + t.Run("explicit conflict", func(t *testing.T) { + if _, _, err := ResolveNamespaceSelection("prod", "team-a,team-b", true, true); err == nil { + t.Fatal("ResolveNamespaceSelection conflict returned nil error") + } + }) +} + func TestConfigureNamespaceScopePreferenceResolverUsesSingleSavedLocalPick(t *testing.T) { t.Setenv("HOME", t.TempDir()) k8s.ResetTestState() diff --git a/internal/config/config.go b/internal/config/config.go index f66d893c6..4023c066b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ type Config struct { Kubeconfig string `json:"kubeconfig,omitempty"` KubeconfigDirs []string `json:"kubeconfigDirs,omitempty"` Namespace string `json:"namespace,omitempty"` + Namespaces []string `json:"namespaces,omitempty"` Port int `json:"port,omitempty"` NoBrowser bool `json:"noBrowser,omitempty"` Browser string `json:"browser,omitempty"` @@ -212,3 +213,9 @@ func ParseByteSize(raw string) (int64, error) { func (c Config) KubeconfigDirsFlag() string { return strings.Join(c.KubeconfigDirs, ",") } + +// NamespacesFlag returns Namespaces joined as a comma-separated string +// suitable for use as a flag default value. +func (c Config) NamespacesFlag() string { + return strings.Join(c.Namespaces, ",") +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4bac7ff2b..f51e69db9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -32,6 +32,7 @@ func TestSaveAndLoad(t *testing.T) { Kubeconfig: "/tmp/kubeconfig", KubeconfigDirs: []string{"/dir1", "/dir2"}, Namespace: "prod", + Namespaces: []string{"dev", "staging"}, Port: 9999, NoBrowser: true, Browser: "firefox", @@ -71,6 +72,9 @@ func TestSaveAndLoad(t *testing.T) { if got.Namespace != want.Namespace { t.Errorf("Namespace = %q, want %q", got.Namespace, want.Namespace) } + if len(got.Namespaces) != 2 || got.Namespaces[0] != "dev" || got.Namespaces[1] != "staging" { + t.Errorf("Namespaces = %v, want %v", got.Namespaces, want.Namespaces) + } if got.NoBrowser != want.NoBrowser { t.Errorf("NoBrowser = %v, want %v", got.NoBrowser, want.NoBrowser) } @@ -224,6 +228,16 @@ func TestHelpers(t *testing.T) { t.Errorf("got %q, want %q", c.KubeconfigDirsFlag(), "/a,/b") } }) + + t.Run("NamespacesFlag", func(t *testing.T) { + if (Config{}).NamespacesFlag() != "" { + t.Error("nil namespaces should return empty string") + } + c := Config{Namespaces: []string{"dev", "prod"}} + if c.NamespacesFlag() != "dev,prod" { + t.Errorf("got %q, want %q", c.NamespacesFlag(), "dev,prod") + } + }) } func TestLoadInvalidJSON(t *testing.T) { diff --git a/internal/k8s/cache.go b/internal/k8s/cache.go index 3d254c94f..7f8b83a8b 100644 --- a/internal/k8s/cache.go +++ b/internal/k8s/cache.go @@ -152,17 +152,18 @@ func InitResourceCache(ctx context.Context) error { } cfg := k8score.CacheConfig{ - Client: k8sClient, - ResourceScopes: scopes, - DeferredTypes: deferredResources, - DebugEvents: DebugEvents, - TimingLogger: logTiming, - PatienceWindow: firstPaintPatience, - MinimalSet: minimalFirstPaintSet, - SyncTimeout: FirstPaintBackstop, - SyncProgress: emitSyncProgress, - DeferredSyncTimeout: 3 * time.Minute, - ListPageSize: ListPageSize, + Client: k8sClient, + ResourceScopes: scopes, + ResourceScopeNamespaces: permResult.ScopeNamespaces, + DeferredTypes: deferredResources, + DebugEvents: DebugEvents, + TimingLogger: logTiming, + PatienceWindow: firstPaintPatience, + MinimalSet: minimalFirstPaintSet, + SyncTimeout: FirstPaintBackstop, + SyncProgress: emitSyncProgress, + DeferredSyncTimeout: 3 * time.Minute, + ListPageSize: ListPageSize, OnReceived: func(kind string) { timeline.IncrementReceived(kind) diff --git a/internal/k8s/capabilities.go b/internal/k8s/capabilities.go index 515897be4..4c50d9a23 100644 --- a/internal/k8s/capabilities.go +++ b/internal/k8s/capabilities.go @@ -61,14 +61,16 @@ type ResourcePermissions struct { // any scope works; NamespaceScoped=true if at least one kind ended up // namespace-scoped). Used by callers that just want a "can the user see // anything?" answer. -// - Scopes: the per-kind authoritative map that drives informer wiring — -// some kinds may be cluster-wide while others are namespace-scoped on -// the same cluster, which the uniform view cannot express. +// - Scopes / ScopeNamespaces: the per-kind authoritative map that drives +// informer wiring — some kinds may be cluster-wide while others are +// namespace-scoped on the same cluster, and a namespace-scoped kind may +// be readable in several explicitly named namespaces. type PermissionCheckResult struct { Perms *ResourcePermissions NamespaceScoped bool // True if at least one resource type ended up namespace-scoped Namespace string // The fallback namespace used for namespace-scoped probes Scopes map[string]k8score.ResourceScope + ScopeNamespaces map[string][]string // Per-kind namespace-scoped informer fanout; nil/empty means use Scopes[key].Namespace } // Capabilities represents the features available based on RBAC permissions @@ -887,11 +889,16 @@ func CheckResourcePermissions(ctx context.Context) *PermissionCheckResult { for k, v := range cachedPermResult.Scopes { scopesCopy[k] = v } + scopeNamespacesCopy := make(map[string][]string, len(cachedPermResult.ScopeNamespaces)) + for k, v := range cachedPermResult.ScopeNamespaces { + scopeNamespacesCopy[k] = append([]string(nil), v...) + } result := &PermissionCheckResult{ Perms: &permsCopy, NamespaceScoped: cachedPermResult.NamespaceScoped, Namespace: cachedPermResult.Namespace, Scopes: scopesCopy, + ScopeNamespaces: scopeNamespacesCopy, } resourcePermsMu.RUnlock() return result @@ -948,11 +955,12 @@ func buildScopeCandidates(ctx context.Context) []string { // over --namespace). Reach into both globals so when an operator sets // `--namespace` distinct from the context, both surface as candidates. clientMu.RLock() - ctxNs, flagNs := contextNamespace, fallbackNamespace + ctxNs := contextNamespace + flagNamespaces := fallbackNamespaceCandidatesLocked() clientMu.RUnlock() accessible, authoritative := GetAccessibleNamespaces(ctx) - out, dropped := mergeScopeCandidates(ctxNs, flagNs, accessible, authoritative) + out, dropped := mergeScopeCandidateLists(ctxNs, flagNamespaces, accessible, authoritative) if !authoritative { // Authoritative=false means the user can't list namespaces. Without // that list the probe can only try whatever the operator named @@ -990,6 +998,10 @@ func mergeForcedScopeCandidate(target string, candidates []string) []string { // authoritative is false the accessible list is ignored — the caller has // already decided that list is not trustworthy as a probe target. func mergeScopeCandidates(ctxNs, flagNs string, accessible []string, authoritative bool) (out []string, dropped int) { + return mergeScopeCandidateLists(ctxNs, []string{flagNs}, accessible, authoritative) +} + +func mergeScopeCandidateLists(ctxNs string, flagNamespaces []string, accessible []string, authoritative bool) (out []string, dropped int) { seen := map[string]bool{} out = make([]string, 0, MaxScopeCandidates) atCap := false @@ -1008,7 +1020,9 @@ func mergeScopeCandidates(ctxNs, flagNs string, accessible []string, authoritati } } add(ctxNs) - add(flagNs) + for _, ns := range flagNamespaces { + add(ns) + } if !authoritative { return out, 0 } @@ -1074,7 +1088,8 @@ func probeResourceAccess(ctx context.Context, dyn dynamic.Interface, scopeNamesp probes := resourceProbeTargets(perms) type probeOutcome struct { - scope k8score.ResourceScope + scope k8score.ResourceScope + namespaces []string } outcomes := make([]probeOutcome, len(probes)) @@ -1146,18 +1161,24 @@ func probeResourceAccess(ctx context.Context, dyn dynamic.Interface, scopeNamesp if !forbidden || p.clusterOnly || len(scopeNamespaces) == 0 { return } - // Try candidate namespaces in priority order. First grant wins. - // Multi-ns users (secret-list in NS A and NS B but neither - // cluster-wide) get partial coverage of one ns per kind — proper - // multi-ns scope per kind would need the cache to support it. + // Try candidate namespaces in priority order. Every grant becomes + // part of the per-kind informer fanout; Namespace keeps the first + // grant as the stable primary for legacy diagnostics and dynamic + // fallback paths. + grantedNamespaces := make([]string, 0, len(scopeNamespaces)) for _, ns := range scopeNamespaces { nsAllowed, _, nsTransient := probeKindAccess(ctx, dyn, p, ns) if nsTransient != nil { hadErrors.Store(true) } if nsAllowed { - outcomes[i] = probeOutcome{scope: k8score.ResourceScope{Enabled: true, Namespace: ns}} - return + grantedNamespaces = append(grantedNamespaces, ns) + } + } + if len(grantedNamespaces) > 0 { + outcomes[i] = probeOutcome{ + scope: k8score.ResourceScope{Enabled: true, Namespace: grantedNamespaces[0]}, + namespaces: grantedNamespaces, } } }(i, p) @@ -1173,6 +1194,7 @@ func probeResourceAccess(ctx context.Context, dyn dynamic.Interface, scopeNamesp // Apply outcomes to perms (boolean projection) and build the scope map. scopes := make(map[string]k8score.ResourceScope, len(probes)) + scopeNamespacesByKind := make(map[string][]string) namespaceScoped := false var ( restricted []string @@ -1186,6 +1208,9 @@ func probeResourceAccess(ctx context.Context, dyn dynamic.Interface, scopeNamesp if r.scope.Namespace != "" { namespaceScoped = true nsScopedKeys = append(nsScopedKeys, p.key) + if len(r.namespaces) > 1 { + scopeNamespacesByKind[p.key] = append([]string(nil), r.namespaces...) + } } } else { restricted = append(restricted, p.key) @@ -1198,7 +1223,15 @@ func probeResourceAccess(ctx context.Context, dyn dynamic.Interface, scopeNamesp // inject lines. CodeQL doesn't model %q escaping, so be explicit. scopedDetail := make([]string, 0, len(nsScopedKeys)) for _, k := range nsScopedKeys { - scopedDetail = append(scopedDetail, fmt.Sprintf("%s=%s", k, SanitizeForLog(scopes[k].Namespace))) + if namespaces := scopeNamespacesByKind[k]; len(namespaces) > 1 { + safeNamespaces := make([]string, 0, len(namespaces)) + for _, ns := range namespaces { + safeNamespaces = append(safeNamespaces, SanitizeForLog(ns)) + } + scopedDetail = append(scopedDetail, fmt.Sprintf("%s=%q", k, safeNamespaces)) + } else { + scopedDetail = append(scopedDetail, fmt.Sprintf("%s=%s", k, SanitizeForLog(scopes[k].Namespace))) + } } sort.Strings(scopedDetail) candidatesLog := make([]string, 0, len(scopeNamespaces)) @@ -1241,6 +1274,7 @@ func probeResourceAccess(ctx context.Context, dyn dynamic.Interface, scopeNamesp NamespaceScoped: namespaceScoped, Namespace: primaryNs, Scopes: scopes, + ScopeNamespaces: scopeNamespacesByKind, }, hadErrors.Load() } @@ -1259,11 +1293,16 @@ func GetCachedPermissionResult() *PermissionCheckResult { for k, v := range cachedPermResult.Scopes { scopesCopy[k] = v } + scopeNamespacesCopy := make(map[string][]string, len(cachedPermResult.ScopeNamespaces)) + for k, v := range cachedPermResult.ScopeNamespaces { + scopeNamespacesCopy[k] = append([]string(nil), v...) + } return &PermissionCheckResult{ Perms: &permsCopy, NamespaceScoped: cachedPermResult.NamespaceScoped, Namespace: cachedPermResult.Namespace, Scopes: scopesCopy, + ScopeNamespaces: scopeNamespacesCopy, } } diff --git a/internal/k8s/capabilities_probe_test.go b/internal/k8s/capabilities_probe_test.go index 804ee5907..6ab89b536 100644 --- a/internal/k8s/capabilities_probe_test.go +++ b/internal/k8s/capabilities_probe_test.go @@ -5,6 +5,7 @@ import ( "fmt" "reflect" "testing" + "time" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" @@ -270,6 +271,55 @@ func TestProbeResourceAccess_FallbackCandidatesBeyondFirst(t *testing.T) { } } +func TestProbeResourceAccess_FallbackCollectsMultipleNamespaces(t *testing.T) { + const nsA, nsB = "team-a", "team-b" + dyn := fakeDyn(t, func(gvr schema.GroupVersionResource, namespace string) bool { + return gvr.Group == "" && gvr.Resource == "pods" && (namespace == nsA || namespace == nsB) + }) + + result, _ := probeResourceAccess(context.Background(), dyn, []string{nsA, nsB, "denied"}, false) + + if got := scopeOf(result, k8score.Pods); got != (k8score.ResourceScope{Enabled: true, Namespace: nsA}) { + t.Fatalf("Pods scope = %+v, want primary namespace %q", got, nsA) + } + if !reflect.DeepEqual(result.ScopeNamespaces[k8score.Pods], []string{nsA, nsB}) { + t.Fatalf("Pods ScopeNamespaces = %v, want [%s %s]", result.ScopeNamespaces[k8score.Pods], nsA, nsB) + } + if result.Namespace != nsA { + t.Fatalf("result.Namespace = %q, want %q", result.Namespace, nsA) + } +} + +func TestCheckResourcePermissionsCacheHitCopiesScopeNamespaces(t *testing.T) { + const nsA, nsB = "team-a", "team-b" + resourcePermsMu.Lock() + cachedPermResult = &PermissionCheckResult{ + Perms: &ResourcePermissions{Pods: true}, + NamespaceScoped: true, + Namespace: nsA, + Scopes: map[string]k8score.ResourceScope{ + k8score.Pods: {Enabled: true, Namespace: nsA}, + }, + ScopeNamespaces: map[string][]string{ + k8score.Pods: {nsA, nsB}, + }, + } + resourcePermsExpiry = time.Now().Add(time.Minute) + resourcePermsMu.Unlock() + t.Cleanup(InvalidateResourcePermissionsCache) + + got := CheckResourcePermissions(context.Background()) + if !reflect.DeepEqual(got.ScopeNamespaces[k8score.Pods], []string{nsA, nsB}) { + t.Fatalf("ScopeNamespaces = %v, want [%s %s]", got.ScopeNamespaces[k8score.Pods], nsA, nsB) + } + got.ScopeNamespaces[k8score.Pods][0] = "mutated" + + got = CheckResourcePermissions(context.Background()) + if !reflect.DeepEqual(got.ScopeNamespaces[k8score.Pods], []string{nsA, nsB}) { + t.Fatalf("cached ScopeNamespaces was mutated through caller copy: %v", got.ScopeNamespaces[k8score.Pods]) + } +} + // TestProbeResourceAccess_MixedScope verifies that each kind is probed // independently: a kind with cluster-wide read access (e.g. Events) must // not suppress the namespace-scoped retry for other kinds in the same run. diff --git a/internal/k8s/client.go b/internal/k8s/client.go index bfe4e8a02..0a9ac8104 100644 --- a/internal/k8s/client.go +++ b/internal/k8s/client.go @@ -54,11 +54,12 @@ var ( // destroyed clusters / removed contexts linger in the dropdown // (they only error out when the user tries to switch to them). // Same lifecycle / lock as perFileConfigs. - perFileMtimes map[string]time.Time - contextName string - clusterName string - contextNamespace string // Default namespace from kubeconfig context - fallbackNamespace string // Explicit namespace from --namespace flag + perFileMtimes map[string]time.Time + contextName string + clusterName string + contextNamespace string // Default namespace from kubeconfig context + fallbackNamespace string // Explicit namespace from --namespace flag + fallbackNamespaces []string // Explicit namespace candidates from --namespaces flag // fallbackNamespaceContext is the context that was active when --namespace was // set at startup. --namespace is an *initial* value, so it only pins the cache // scope for that context — after switching clusters, the scope target comes @@ -66,7 +67,7 @@ var ( fallbackNamespaceContext string namespaceScopeOverride string // Runtime namespace selected by local --namespace-scope rescope namespaceScopeResolver func(contextName string) (string, bool) - contextUsesExec bool // True when the current context uses an exec credential plugin + contextUsesExec bool // True when the current context uses an exec credential plugin // execPluginCommands is the set of unique exec-auth plugin command basenames // referenced by any context in the merged kubeconfig. Populated from // rawConfig.AuthInfos at load time and refreshed on SwitchContext. Stored @@ -676,11 +677,57 @@ func SetFallbackNamespace(ns string) { clientMu.Lock() defer clientMu.Unlock() fallbackNamespace = ns + fallbackNamespaces = dedupeNamespacesLocked([]string{ns}) // Record the context this --namespace applies to, so it only pins the cache // scope while we're on that context (see GetNamespaceScopeTarget). fallbackNamespaceContext = contextName } +// SetFallbackNamespaces sets explicit namespace candidates from --namespaces. +// The first namespace also becomes the legacy fallback namespace so older +// single-namespace code paths have a deterministic default, but the full list +// is preserved for RBAC probing and namespace discovery. +func SetFallbackNamespaces(namespaces []string) { + clientMu.Lock() + defer clientMu.Unlock() + fallbackNamespaces = dedupeNamespacesLocked(namespaces) + if len(fallbackNamespaces) > 0 { + fallbackNamespace = fallbackNamespaces[0] + } else { + fallbackNamespace = "" + } + fallbackNamespaceContext = contextName +} + +func dedupeNamespacesLocked(namespaces []string) []string { + if len(namespaces) == 0 { + return nil + } + seen := make(map[string]struct{}, len(namespaces)) + out := make([]string, 0, len(namespaces)) + for _, ns := range namespaces { + ns = strings.TrimSpace(ns) + if ns == "" { + continue + } + if _, ok := seen[ns]; ok { + continue + } + seen[ns] = struct{}{} + out = append(out, ns) + } + return out +} + +func fallbackNamespaceCandidatesLocked() []string { + candidates := make([]string, 0, len(fallbackNamespaces)+1) + candidates = append(candidates, fallbackNamespaces...) + if fallbackNamespace != "" { + candidates = append(candidates, fallbackNamespace) + } + return dedupeNamespacesLocked(candidates) +} + // SetNamespaceScopeOverride sets the runtime namespace used by local // --namespace-scope rescope. It is separate from fallbackNamespace so a real // kubeconfig context switch can drop the runtime pick without losing the @@ -783,7 +830,7 @@ func GetEffectiveNamespace() string { func HasNamespaceFallback() bool { clientMu.RLock() defer clientMu.RUnlock() - return contextNamespace != "" || fallbackNamespace != "" + return contextNamespace != "" || fallbackNamespace != "" || len(fallbackNamespaces) > 0 } // GetAccessibleNamespaces returns the list of namespaces the user has @@ -825,7 +872,8 @@ func GetAccessibleNamespaces(ctx context.Context) ([]string, bool) { seen := map[string]bool{} var fallback []string clientMu.RLock() - for _, ns := range []string{contextNamespace, fallbackNamespace} { + candidates := append([]string{contextNamespace}, fallbackNamespaceCandidatesLocked()...) + for _, ns := range candidates { if ns != "" && !seen[ns] { seen[ns] = true fallback = append(fallback, ns) diff --git a/internal/server/namespace_scope.go b/internal/server/namespace_scope.go index 105686363..6170bd11f 100644 --- a/internal/server/namespace_scope.go +++ b/internal/server/namespace_scope.go @@ -88,7 +88,7 @@ func (s *Server) setActiveNamespaceForUser(r *http.Request, namespaces []string) } key := nsPreferenceKey(username, ctxName) if len(namespaces) == 0 { - s.nsPreferences.Delete(key) + s.nsPreferences.Store(key, []string{}) return } // Defensive copy so callers can mutate their input safely after the store. @@ -124,27 +124,35 @@ func (s *Server) finalizePostContextSwitch() { s.clearAllNamespacePreferences() } -// loadSavedNamespacePreference seeds the per-user map from settings.json on -// first reach. Only relevant for the no-auth (local single-user) path — -// auth-enabled deploys don't persist picks across pod restarts. +// loadSavedNamespacePreference seeds the per-user map on first reach. Local +// saved picks win over the configured --namespaces startup default so a +// remembered narrower choice survives process restarts. Auth-enabled sessions +// do not read local settings; for them --namespaces is the initial session +// view. func (s *Server) loadSavedNamespacePreference(r *http.Request) { - if auth.UserFromContext(r.Context()) != nil { - return // multi-user: no shared persisted pref - } ctxName := k8s.GetContextName() if ctxName == "" { return } - key := nsPreferenceKey("", ctxName) + username := "" + if u := auth.UserFromContext(r.Context()); u != nil { + username = u.Username + } + key := nsPreferenceKey(username, ctxName) if _, ok := s.nsPreferences.Load(key); ok { return } - saved := settings.Load() - if saved.ActiveNamespaces == nil { - return + if username == "" { + saved := settings.Load() + if saved.ActiveNamespaces != nil { + if picks := saved.ActiveNamespaces[ctxName]; len(picks) > 0 { + s.nsPreferences.Store(key, append([]string(nil), picks...)) + return + } + } } - if picks := saved.ActiveNamespaces[ctxName]; len(picks) > 0 { - s.nsPreferences.Store(key, append([]string(nil), picks...)) + if len(s.configuredNamespaces) > 0 { + s.nsPreferences.Store(key, append([]string(nil), s.configuredNamespaces...)) } } diff --git a/internal/server/namespace_scope_test.go b/internal/server/namespace_scope_test.go index 34f7f23db..35c1c1fb7 100644 --- a/internal/server/namespace_scope_test.go +++ b/internal/server/namespace_scope_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/skyhook-io/radar/internal/k8s" + "github.com/skyhook-io/radar/internal/settings" pkgauth "github.com/skyhook-io/radar/pkg/auth" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" @@ -104,6 +105,42 @@ func TestSetActiveNamespaceForUser_NoAuth(t *testing.T) { } } +func TestLoadSavedNamespacePreference_ConfiguredNamespacesSeedOnce(t *testing.T) { + s := newTestServer(t) + s.configuredNamespaces = []string{"team-a", "team-b"} + req := reqAs("alice") + + s.loadSavedNamespacePreference(req) + if got := s.getActiveNamespaceForUser(req); !slices.Equal(got, []string{"team-a", "team-b"}) { + t.Fatalf("configured namespace seed = %v, want [team-a team-b]", got) + } + + s.setActiveNamespaceForUser(req, nil) + s.loadSavedNamespacePreference(req) + if got := s.getActiveNamespaceForUser(req); len(got) != 0 { + t.Fatalf("configured namespace list re-seeded after clear: %v", got) + } +} + +func TestLoadSavedNamespacePreference_LocalSavedPickWinsOverConfiguredNamespaces(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) + s := newTestServer(t) + s.configuredNamespaces = []string{"team-a", "team-b"} + if _, err := settings.Update(func(st *settings.Settings) { + st.ActiveNamespaces = map[string][]string{"test-ctx": {"team-b"}} + }); err != nil { + t.Fatalf("settings.Update: %v", err) + } + + req := reqAs("") + s.loadSavedNamespacePreference(req) + if got := s.getActiveNamespaceForUser(req); !slices.Equal(got, []string{"team-b"}) { + t.Fatalf("saved pick = %v, want [team-b]", got) + } +} + func TestSetActiveNamespaceForUser_DefensiveCopy(t *testing.T) { // Mutating the caller's slice after a Set must not corrupt stored state. s := newTestServer(t) diff --git a/internal/server/server.go b/internal/server/server.go index c66ffee9b..83044faf0 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -55,21 +55,22 @@ import ( // Server is the Explorer HTTP server type Server struct { - router *chi.Mux - broadcaster *SSEBroadcaster - port int - devMode bool - staticFS fs.FS - startTime time.Time - listener net.Listener - updater *updater.Updater - mcpHandler http.Handler - diagConfig *DiagConfig - effectiveConfig *config.Config // running config for GET /api/config - authConfig auth.Config - permCache *auth.PermissionCache - oidcHandler *auth.OIDCHandler - saveFileFunc func(defaultFilename string, data []byte) (string, error) + router *chi.Mux + broadcaster *SSEBroadcaster + port int + devMode bool + staticFS fs.FS + startTime time.Time + listener net.Listener + updater *updater.Updater + mcpHandler http.Handler + diagConfig *DiagConfig + effectiveConfig *config.Config // running config for GET /api/config + authConfig auth.Config + configuredNamespaces []string + permCache *auth.PermissionCache + oidcHandler *auth.OIDCHandler + saveFileFunc func(defaultFilename string, data []byte) (string, error) // nsPreferences holds each user's active-namespace pick from the in-app // switcher. Key shape: "\x00" when auth is enabled, @@ -104,14 +105,15 @@ type Server struct { // Config holds server configuration type Config struct { - Port int - DevMode bool // Serve frontend from filesystem instead of embedded - StaticFS embed.FS // Embedded frontend files - StaticRoot string // Path within StaticFS - MCPHandler http.Handler // MCP server handler (nil = MCP disabled) - DiagConfig *DiagConfig // Sanitized config for diagnostics endpoint - EffectiveConfig *config.Config // Running startup config for GET /api/config - AuthConfig auth.Config // Authentication configuration + Port int + DevMode bool // Serve frontend from filesystem instead of embedded + StaticFS embed.FS // Embedded frontend files + StaticRoot string // Path within StaticFS + MCPHandler http.Handler // MCP server handler (nil = MCP disabled) + DiagConfig *DiagConfig // Sanitized config for diagnostics endpoint + EffectiveConfig *config.Config // Running startup config for GET /api/config + AuthConfig auth.Config // Authentication configuration + ConfiguredNamespaces []string // Initial namespace picks from --namespaces } // New creates a new server instance @@ -119,17 +121,18 @@ func New(cfg Config) *Server { cfg.AuthConfig.Defaults() s := &Server{ - router: chi.NewRouter(), - broadcaster: NewSSEBroadcaster(), - port: cfg.Port, - devMode: cfg.DevMode, - startTime: time.Now(), - mcpHandler: cfg.MCPHandler, - diagConfig: cfg.DiagConfig, - effectiveConfig: cfg.EffectiveConfig, - authConfig: cfg.AuthConfig, - topoMemo: topology.NewMemoizer(5 * time.Second), - rbacMemo: rbac.NewMemoizer(5 * time.Second), + router: chi.NewRouter(), + broadcaster: NewSSEBroadcaster(), + port: cfg.Port, + devMode: cfg.DevMode, + startTime: time.Now(), + mcpHandler: cfg.MCPHandler, + diagConfig: cfg.DiagConfig, + effectiveConfig: cfg.EffectiveConfig, + authConfig: cfg.AuthConfig, + configuredNamespaces: append([]string(nil), cfg.ConfiguredNamespaces...), + topoMemo: topology.NewMemoizer(5 * time.Second), + rbacMemo: rbac.NewMemoizer(5 * time.Second), } // Register a single context-switch callback so every PerformContextSwitch diff --git a/pkg/k8score/cache.go b/pkg/k8score/cache.go index 57e27eb34..367acd0db 100644 --- a/pkg/k8score/cache.go +++ b/pkg/k8score/cache.go @@ -31,6 +31,7 @@ type ResourceCache struct { factory informers.SharedInformerFactory // cluster-wide factory (always present) nsFactories map[string]informers.SharedInformerFactory // per-namespace factories (for mixed-scope mode) factoryByKind map[string]informers.SharedInformerFactory // resolved factory per enabled kind — listers MUST go through this + indexerByKind map[string]cache.Indexer // union indexer for kinds watched in multiple namespace factories pagedInformers map[string]cache.SharedIndexInformer // per-kind paginating informers (ListPageSize>0); listers read these instead of the factory's changes chan ResourceChange stopCh chan struct{} @@ -134,6 +135,72 @@ func newPagedInformer( return inf } +func cloneScopeNamespaces(in map[string][]string) map[string][]string { + if in == nil { + return nil + } + out := make(map[string][]string, len(in)) + for k, v := range in { + if len(v) == 0 { + continue + } + out[k] = append([]string(nil), v...) + } + return out +} + +func resourceScopeNamespaces(key string, scope ResourceScope, byKind map[string][]string) []string { + if !scope.Enabled || scope.Namespace == "" { + return nil + } + namespaces := byKind[key] + if len(namespaces) == 0 { + return []string{scope.Namespace} + } + seen := make(map[string]struct{}, len(namespaces)+1) + out := make([]string, 0, len(namespaces)+1) + add := func(ns string) { + if ns == "" { + return + } + if _, ok := seen[ns]; ok { + return + } + seen[ns] = struct{}{} + out = append(out, ns) + } + add(scope.Namespace) + for _, ns := range namespaces { + add(ns) + } + return out +} + +func newUnionIndexer() cache.Indexer { + return cache.NewIndexer( + cache.MetaNamespaceKeyFunc, + cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, + ) +} + +func mirrorInformerToIndexer(inf cache.SharedIndexInformer, idx cache.Indexer) error { + _, err := inf.AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj any) { + _ = idx.Add(obj) + }, + UpdateFunc: func(_, newObj any) { + _ = idx.Update(newObj) + }, + DeleteFunc: func(obj any) { + if tombstone, ok := obj.(cache.DeletedFinalStateUnknown); ok { + obj = tombstone.Obj + } + _ = idx.Delete(obj) + }, + }) + return err +} + // NewResourceCache creates and starts a ResourceCache from the given config. // Startup has three tiers: // - Critical informers block until synced. With PatienceWindow + MinimalSet, @@ -157,6 +224,21 @@ func NewResourceCache(cfg CacheConfig) (*ResourceCache, error) { return nil, fmt.Errorf("CacheConfig.ResourceScopes contains entry with empty key") } } + for k, namespaces := range cfg.ResourceScopeNamespaces { + if k == "" { + return nil, fmt.Errorf("CacheConfig.ResourceScopeNamespaces contains entry with empty key") + } + if len(namespaces) == 0 { + continue + } + scope, ok := cfg.ResourceScopes[k] + if !ok || !scope.Enabled { + return nil, fmt.Errorf("CacheConfig.ResourceScopeNamespaces contains namespaces for disabled key %q", k) + } + if scope.Namespace == "" { + return nil, fmt.Errorf("CacheConfig.ResourceScopeNamespaces contains namespaces for cluster-wide key %q", k) + } + } channelSize := cfg.ChannelSize if channelSize <= 0 { @@ -176,6 +258,7 @@ func NewResourceCache(cfg CacheConfig) (*ResourceCache, error) { // Clone caller-owned maps to prevent mutation after construction. cfg.ResourceTypes = maps.Clone(cfg.ResourceTypes) cfg.ResourceScopes = maps.Clone(cfg.ResourceScopes) + cfg.ResourceScopeNamespaces = cloneScopeNamespaces(cfg.ResourceScopeNamespaces) cfg.DeferredTypes = maps.Clone(cfg.DeferredTypes) cfg.MinimalSet = maps.Clone(cfg.MinimalSet) @@ -215,19 +298,22 @@ func NewResourceCache(cfg CacheConfig) (*ResourceCache, error) { informers.WithTransform(DropManagedFields), ) nsFactories := make(map[string]informers.SharedInformerFactory) - for _, s := range scopes { - if s.Namespace == "" { + for key, s := range scopes { + namespaces := resourceScopeNamespaces(key, s, cfg.ResourceScopeNamespaces) + if len(namespaces) == 0 { continue } - if _, ok := nsFactories[s.Namespace]; ok { - continue + for _, namespace := range namespaces { + if _, ok := nsFactories[namespace]; ok { + continue + } + nsFactories[namespace] = informers.NewSharedInformerFactoryWithOptions( + cfg.Client, + 0, + informers.WithTransform(DropManagedFields), + informers.WithNamespace(namespace), + ) } - nsFactories[s.Namespace] = informers.NewSharedInformerFactoryWithOptions( - cfg.Client, - 0, - informers.WithTransform(DropManagedFields), - informers.WithNamespace(s.Namespace), - ) } if len(nsFactories) > 0 { var nsList []string @@ -277,6 +363,7 @@ func NewResourceCache(cfg CacheConfig) (*ResourceCache, error) { factory: clusterFactory, nsFactories: nsFactories, factoryByKind: map[string]informers.SharedInformerFactory{}, + indexerByKind: map[string]cache.Indexer{}, pagedInformers: map[string]cache.SharedIndexInformer{}, changes: changes, stopCh: stopCh, @@ -292,30 +379,18 @@ func NewResourceCache(cfg CacheConfig) (*ResourceCache, error) { key string deferred bool synced cache.InformerSynced - informer cache.SharedIndexInformer + run func(stopCh <-chan struct{}) } var allEntries []informerEntry - for _, s := range setups { - if !enabled[s.key] { - continue - } - enabledCount++ - factory := pickFactory(s) - rc.factoryByKind[s.key] = factory - - var inf cache.SharedIndexInformer + buildInformer := func(s informerSetup, factory informers.SharedInformerFactory, namespace string) cache.SharedIndexInformer { if cfg.ListPageSize > 0 && s.pagedSetup != nil { - // scopes[s.key].Namespace is "" for cluster-wide access or the - // single namespace a restricted user is scoped to. - inf = s.pagedSetup(cfg.Client, scopes[s.key].Namespace, cfg.ListPageSize) - // Listers must read this informer's store, not the factory's - // (the factory's informer for this kind is never started). - rc.pagedInformers[s.key] = inf - } else { - inf = s.setup(factory) + return s.pagedSetup(cfg.Client, namespace, cfg.ListPageSize) } + return s.setup(factory) + } + wireInformer := func(inf cache.SharedIndexInformer, s informerSetup, idx int) error { var err error if s.isEvent { err = rc.addEventHandlers(inf, changes) @@ -323,8 +398,7 @@ func NewResourceCache(cfg CacheConfig) (*ResourceCache, error) { err = rc.addChangeHandlers(inf, s.kind, changes) } if err != nil { - close(stopCh) - return nil, fmt.Errorf("failed to register %s event handler: %w", s.kind, err) + return fmt.Errorf("failed to register %s event handler: %w", s.kind, err) } // Wire a WatchErrorHandler so reflector-level failures (most @@ -333,7 +407,6 @@ func NewResourceCache(cfg CacheConfig) (*ResourceCache, error) { // mid-session) are visible in diagnostics. The reflector keeps // retrying with exponential backoff regardless; this just makes // the failure observable instead of silent. - idx := len(allEntries) // status index for this informer key := s.key kind := s.kind if hErr := inf.SetWatchErrorHandler(func(_ *cache.Reflector, err error) { @@ -343,21 +416,85 @@ func NewResourceCache(cfg CacheConfig) (*ResourceCache, error) { // started, which can't happen here — log and continue. stdlog.Printf("Warning: failed to set watch error handler for %s: %v", s.kind, hErr) } + return nil + } + + for _, s := range setups { + if !enabled[s.key] { + continue + } + enabledCount++ + idx := len(allEntries) // status index for this logical kind + scopeNamespaces := resourceScopeNamespaces(s.key, scopes[s.key], cfg.ResourceScopeNamespaces) + + var synced cache.InformerSynced + var run func(stopCh <-chan struct{}) + + if !s.isClusterScoped && len(scopeNamespaces) > 1 { + unionIndexer := newUnionIndexer() + rc.indexerByKind[s.key] = unionIndexer + informersForKind := make([]cache.SharedIndexInformer, 0, len(scopeNamespaces)) + for _, namespace := range scopeNamespaces { + factory := nsFactories[namespace] + inf := buildInformer(s, factory, namespace) + if err := mirrorInformerToIndexer(inf, unionIndexer); err != nil { + close(stopCh) + return nil, fmt.Errorf("failed to register %s union indexer handler: %w", s.kind, err) + } + if err := wireInformer(inf, s, idx); err != nil { + close(stopCh) + return nil, err + } + informersForKind = append(informersForKind, inf) + } + synced = func() bool { + for _, inf := range informersForKind { + if !inf.HasSynced() { + return false + } + } + return true + } + run = func(stopCh <-chan struct{}) { + for _, inf := range informersForKind { + go inf.Run(stopCh) + } + } + } else { + factory := pickFactory(s) + rc.factoryByKind[s.key] = factory + namespace := scopes[s.key].Namespace + if s.isClusterScoped { + namespace = "" + } + inf := buildInformer(s, factory, namespace) + if cfg.ListPageSize > 0 && s.pagedSetup != nil { + // Listers must read this informer's store, not the factory's + // (the factory's informer for this kind is never started). + rc.pagedInformers[s.key] = inf + } + if err := wireInformer(inf, s, idx); err != nil { + close(stopCh) + return nil, err + } + synced = inf.HasSynced + run = inf.Run + } isDeferred := deferredTypes[s.key] - entry := informerEntry{kind: s.kind, key: s.key, deferred: isDeferred, synced: inf.HasSynced, informer: inf} + entry := informerEntry{kind: s.kind, key: s.key, deferred: isDeferred, synced: synced, run: run} allEntries = append(allEntries, entry) if isDeferred && s.isEvent { // Events sync independently — they can take 60s+ on large clusters // and shouldn't block topology completion or warmup transition. - backgroundSyncFuncs = append(backgroundSyncFuncs, inf.HasSynced) + backgroundSyncFuncs = append(backgroundSyncFuncs, synced) backgroundKeys = append(backgroundKeys, s.key) } else if isDeferred { - deferredSyncFuncs = append(deferredSyncFuncs, inf.HasSynced) + deferredSyncFuncs = append(deferredSyncFuncs, synced) deferredKeys = append(deferredKeys, s.key) } else { - criticalSyncFuncs = append(criticalSyncFuncs, inf.HasSynced) + criticalSyncFuncs = append(criticalSyncFuncs, synced) } } @@ -384,7 +521,7 @@ func NewResourceCache(cfg CacheConfig) (*ResourceCache, error) { // bandwidth during the critical path. for _, e := range allEntries { if !e.deferred { - go e.informer.Run(stopCh) + go e.run(stopCh) } } @@ -681,7 +818,7 @@ func NewResourceCache(cfg CacheConfig) (*ResourceCache, error) { // then wait for them in background. This staggers the API server load. for _, e := range allEntries { if e.deferred { - go e.informer.Run(stopCh) + go e.run(stopCh) } } @@ -1208,7 +1345,7 @@ func (rc *ResourceCache) IsKindClusterWide(resource string) bool { return false } s, ok := rc.config.ResourceScopes[resource] - return ok && s.Enabled && s.Namespace == "" + return ok && s.Enabled && s.Namespace == "" && len(rc.config.ResourceScopeNamespaces[resource]) == 0 } // ChangesRaw returns the bidirectional channel for internal use. diff --git a/pkg/k8score/cache_test.go b/pkg/k8score/cache_test.go index 538b4b03b..f78af7cb4 100644 --- a/pkg/k8score/cache_test.go +++ b/pkg/k8score/cache_test.go @@ -16,6 +16,7 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" dynamicfake "k8s.io/client-go/dynamic/fake" @@ -948,6 +949,180 @@ func TestNewResourceCache_ResourceScopesMixed(t *testing.T) { } } +func TestNewResourceCache_ResourceScopeNamespacesUnion(t *testing.T) { + const nsA, nsB, nsOther = "team-a", "team-b", "team-c" + client := fake.NewSimpleClientset( + &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod-a", Namespace: nsA, UID: "pod-a"}}, + &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod-b", Namespace: nsB, UID: "pod-b"}}, + &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod-c", Namespace: nsOther, UID: "pod-c"}}, + ) + + rc, err := NewResourceCache(CacheConfig{ + Client: client, + ResourceScopes: map[string]ResourceScope{ + Pods: {Enabled: true, Namespace: nsA}, + }, + ResourceScopeNamespaces: map[string][]string{ + Pods: {nsA, nsB}, + }, + }) + if err != nil { + t.Fatalf("NewResourceCache failed: %v", err) + } + defer rc.Stop() + + lister := rc.Pods() + if lister == nil { + t.Fatal("Pods lister should exist") + } + all, err := lister.List(labels.Everything()) + if err != nil { + t.Fatalf("list all pods: %v", err) + } + if len(all) != 2 { + t.Fatalf("all pods = %d, want 2 from %s/%s (items=%v)", len(all), nsA, nsB, all) + } + for _, tc := range []struct { + namespace string + want int + }{ + {nsA, 1}, + {nsB, 1}, + {nsOther, 0}, + } { + items, err := lister.Pods(tc.namespace).List(labels.Everything()) + if err != nil { + t.Fatalf("list pods in %s: %v", tc.namespace, err) + } + if len(items) != tc.want { + t.Fatalf("pods in %s = %d, want %d", tc.namespace, len(items), tc.want) + } + } + if got := ListCountNamespaced(lister, []string{nsA, nsB}); got != 2 { + t.Fatalf("ListCountNamespaced = %d, want 2", got) + } + if rc.IsKindClusterWide(Pods) { + t.Fatal("multi-namespace scoped Pods must not report cluster-wide authority") + } +} + +type recordingIndexer struct { + cache.Indexer + addCount atomic.Int32 + updateCount atomic.Int32 +} + +func (r *recordingIndexer) Add(obj any) error { + r.addCount.Add(1) + return r.Indexer.Add(obj) +} + +func (r *recordingIndexer) Update(obj any) error { + r.updateCount.Add(1) + return r.Indexer.Update(obj) +} + +type capturingSharedIndexInformer struct { + handler cache.ResourceEventHandler + indexer cache.Indexer +} + +func (c *capturingSharedIndexInformer) AddEventHandler(handler cache.ResourceEventHandler) (cache.ResourceEventHandlerRegistration, error) { + c.handler = handler + return nil, nil +} + +func (c *capturingSharedIndexInformer) AddEventHandlerWithResyncPeriod(handler cache.ResourceEventHandler, _ time.Duration) (cache.ResourceEventHandlerRegistration, error) { + c.handler = handler + return nil, nil +} + +func (c *capturingSharedIndexInformer) AddEventHandlerWithOptions(handler cache.ResourceEventHandler, _ cache.HandlerOptions) (cache.ResourceEventHandlerRegistration, error) { + c.handler = handler + return nil, nil +} + +func (c *capturingSharedIndexInformer) RemoveEventHandler(cache.ResourceEventHandlerRegistration) error { + return nil +} + +func (c *capturingSharedIndexInformer) GetStore() cache.Store { + return c.indexer +} + +func (c *capturingSharedIndexInformer) GetController() cache.Controller { + return nil +} + +func (c *capturingSharedIndexInformer) Run(<-chan struct{}) {} + +func (c *capturingSharedIndexInformer) RunWithContext(context.Context) {} + +func (c *capturingSharedIndexInformer) HasSynced() bool { + return true +} + +func (c *capturingSharedIndexInformer) HasSyncedChecker() cache.DoneChecker { + return nil +} + +func (c *capturingSharedIndexInformer) LastSyncResourceVersion() string { + return "" +} + +func (c *capturingSharedIndexInformer) SetWatchErrorHandler(cache.WatchErrorHandler) error { + return nil +} + +func (c *capturingSharedIndexInformer) SetWatchErrorHandlerWithContext(cache.WatchErrorHandlerWithContext) error { + return nil +} + +func (c *capturingSharedIndexInformer) SetTransform(cache.TransformFunc) error { + return nil +} + +func (c *capturingSharedIndexInformer) IsStopped() bool { + return false +} + +func (c *capturingSharedIndexInformer) AddIndexers(cache.Indexers) error { + return nil +} + +func (c *capturingSharedIndexInformer) GetIndexer() cache.Indexer { + return c.indexer +} + +func TestMirrorInformerToIndexer_AddFuncUsesAdd(t *testing.T) { + const ns = "team-a" + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "pod-a", Namespace: ns, UID: "pod-a", ResourceVersion: "1"}, + } + idx := &recordingIndexer{Indexer: newUnionIndexer()} + inf := &capturingSharedIndexInformer{indexer: cache.NewIndexer(cache.MetaNamespaceKeyFunc, nil)} + + if err := mirrorInformerToIndexer(inf, idx); err != nil { + t.Fatalf("mirrorInformerToIndexer: %v", err) + } + if inf.handler == nil { + t.Fatal("mirrorInformerToIndexer did not register a handler") + } + inf.handler.OnAdd(pod, true) + + if _, exists, err := idx.GetByKey(ns + "/pod-a"); err != nil { + t.Fatalf("GetByKey: %v", err) + } else if !exists { + t.Fatal("mirrored pod was not added to union indexer") + } + if got := idx.addCount.Load(); got == 0 { + t.Fatal("initial informer add did not call Indexer.Add") + } + if got := idx.updateCount.Load(); got != 0 { + t.Fatalf("initial informer add called Indexer.Update %d time(s), want 0", got) + } +} + // TestNewResourceCache_ResourceScopesEmpty verifies that an explicitly // empty ResourceScopes map (no kinds enabled) produces a cache with no // informers, vs nil ResourceScopes which falls through to the legacy diff --git a/pkg/k8score/listers.go b/pkg/k8score/listers.go index 44877fd2c..e4f9c1b23 100644 --- a/pkg/k8score/listers.go +++ b/pkg/k8score/listers.go @@ -11,6 +11,7 @@ import ( listerspolicyv1 "k8s.io/client-go/listers/policy/v1" listersrbacv1 "k8s.io/client-go/listers/rbac/v1" listersstoragev1 "k8s.io/client-go/listers/storage/v1" + "k8s.io/client-go/tools/cache" ) // factoryFor resolves the informer factory the given enabled kind is wired @@ -25,10 +26,20 @@ func (rc *ResourceCache) factoryFor(key string) informers.SharedInformerFactory return rc.factory } +func (rc *ResourceCache) indexerFor(key string) cache.Indexer { + if rc == nil || rc.indexerByKind == nil { + return nil + } + return rc.indexerByKind[key] +} + func (rc *ResourceCache) Services() listerscorev1.ServiceLister { if rc == nil || !rc.isEnabled(Services) { return nil } + if idx := rc.indexerFor(Services); idx != nil { + return listerscorev1.NewServiceLister(idx) + } return rc.factoryFor(Services).Core().V1().Services().Lister() } @@ -36,6 +47,9 @@ func (rc *ResourceCache) Pods() listerscorev1.PodLister { if rc == nil || !rc.isEnabled(Pods) { return nil } + if idx := rc.indexerFor(Pods); idx != nil { + return listerscorev1.NewPodLister(idx) + } if inf := rc.pagedInformers[Pods]; inf != nil { return listerscorev1.NewPodLister(inf.GetIndexer()) } @@ -60,6 +74,9 @@ func (rc *ResourceCache) ConfigMaps() listerscorev1.ConfigMapLister { if rc == nil || !rc.isReady(ConfigMaps) { return nil } + if idx := rc.indexerFor(ConfigMaps); idx != nil { + return listerscorev1.NewConfigMapLister(idx) + } return rc.factoryFor(ConfigMaps).Core().V1().ConfigMaps().Lister() } @@ -67,6 +84,9 @@ func (rc *ResourceCache) Secrets() listerscorev1.SecretLister { if rc == nil || !rc.isReady(Secrets) { return nil } + if idx := rc.indexerFor(Secrets); idx != nil { + return listerscorev1.NewSecretLister(idx) + } return rc.factoryFor(Secrets).Core().V1().Secrets().Lister() } @@ -74,6 +94,9 @@ func (rc *ResourceCache) Events() listerscorev1.EventLister { if rc == nil || !rc.isReady(Events) { return nil } + if idx := rc.indexerFor(Events); idx != nil { + return listerscorev1.NewEventLister(idx) + } return rc.factoryFor(Events).Core().V1().Events().Lister() } @@ -81,6 +104,9 @@ func (rc *ResourceCache) PersistentVolumeClaims() listerscorev1.PersistentVolume if rc == nil || !rc.isReady(PersistentVolumeClaims) { return nil } + if idx := rc.indexerFor(PersistentVolumeClaims); idx != nil { + return listerscorev1.NewPersistentVolumeClaimLister(idx) + } return rc.factoryFor(PersistentVolumeClaims).Core().V1().PersistentVolumeClaims().Lister() } @@ -95,6 +121,9 @@ func (rc *ResourceCache) Deployments() listersappsv1.DeploymentLister { if rc == nil || !rc.isEnabled(Deployments) { return nil } + if idx := rc.indexerFor(Deployments); idx != nil { + return listersappsv1.NewDeploymentLister(idx) + } return rc.factoryFor(Deployments).Apps().V1().Deployments().Lister() } @@ -102,6 +131,9 @@ func (rc *ResourceCache) DaemonSets() listersappsv1.DaemonSetLister { if rc == nil || !rc.isEnabled(DaemonSets) { return nil } + if idx := rc.indexerFor(DaemonSets); idx != nil { + return listersappsv1.NewDaemonSetLister(idx) + } return rc.factoryFor(DaemonSets).Apps().V1().DaemonSets().Lister() } @@ -109,6 +141,9 @@ func (rc *ResourceCache) StatefulSets() listersappsv1.StatefulSetLister { if rc == nil || !rc.isEnabled(StatefulSets) { return nil } + if idx := rc.indexerFor(StatefulSets); idx != nil { + return listersappsv1.NewStatefulSetLister(idx) + } return rc.factoryFor(StatefulSets).Apps().V1().StatefulSets().Lister() } @@ -116,6 +151,9 @@ func (rc *ResourceCache) ReplicaSets() listersappsv1.ReplicaSetLister { if rc == nil || !rc.isEnabled(ReplicaSets) { return nil } + if idx := rc.indexerFor(ReplicaSets); idx != nil { + return listersappsv1.NewReplicaSetLister(idx) + } if inf := rc.pagedInformers[ReplicaSets]; inf != nil { return listersappsv1.NewReplicaSetLister(inf.GetIndexer()) } @@ -126,6 +164,9 @@ func (rc *ResourceCache) Ingresses() listersnetworkingv1.IngressLister { if rc == nil || !rc.isEnabled(Ingresses) { return nil } + if idx := rc.indexerFor(Ingresses); idx != nil { + return listersnetworkingv1.NewIngressLister(idx) + } return rc.factoryFor(Ingresses).Networking().V1().Ingresses().Lister() } @@ -140,6 +181,9 @@ func (rc *ResourceCache) Jobs() listersbatchv1.JobLister { if rc == nil || !rc.isEnabled(Jobs) { return nil } + if idx := rc.indexerFor(Jobs); idx != nil { + return listersbatchv1.NewJobLister(idx) + } return rc.factoryFor(Jobs).Batch().V1().Jobs().Lister() } @@ -147,6 +191,9 @@ func (rc *ResourceCache) CronJobs() listersbatchv1.CronJobLister { if rc == nil || !rc.isEnabled(CronJobs) { return nil } + if idx := rc.indexerFor(CronJobs); idx != nil { + return listersbatchv1.NewCronJobLister(idx) + } return rc.factoryFor(CronJobs).Batch().V1().CronJobs().Lister() } @@ -154,6 +201,9 @@ func (rc *ResourceCache) HorizontalPodAutoscalers() listersautoscalingv2.Horizon if rc == nil || !rc.isEnabled(HorizontalPodAutoscalers) { return nil } + if idx := rc.indexerFor(HorizontalPodAutoscalers); idx != nil { + return listersautoscalingv2.NewHorizontalPodAutoscalerLister(idx) + } return rc.factoryFor(HorizontalPodAutoscalers).Autoscaling().V2().HorizontalPodAutoscalers().Lister() } @@ -168,6 +218,9 @@ func (rc *ResourceCache) PodDisruptionBudgets() listerspolicyv1.PodDisruptionBud if rc == nil || !rc.isReady(PodDisruptionBudgets) { return nil } + if idx := rc.indexerFor(PodDisruptionBudgets); idx != nil { + return listerspolicyv1.NewPodDisruptionBudgetLister(idx) + } return rc.factoryFor(PodDisruptionBudgets).Policy().V1().PodDisruptionBudgets().Lister() } @@ -175,6 +228,9 @@ func (rc *ResourceCache) NetworkPolicies() listersnetworkingv1.NetworkPolicyList if rc == nil || !rc.isReady(NetworkPolicies) { return nil } + if idx := rc.indexerFor(NetworkPolicies); idx != nil { + return listersnetworkingv1.NewNetworkPolicyLister(idx) + } return rc.factoryFor(NetworkPolicies).Networking().V1().NetworkPolicies().Lister() } @@ -182,6 +238,9 @@ func (rc *ResourceCache) ServiceAccounts() listerscorev1.ServiceAccountLister { if rc == nil || !rc.isEnabled(ServiceAccounts) { return nil } + if idx := rc.indexerFor(ServiceAccounts); idx != nil { + return listerscorev1.NewServiceAccountLister(idx) + } return rc.factoryFor(ServiceAccounts).Core().V1().ServiceAccounts().Lister() } @@ -189,6 +248,9 @@ func (rc *ResourceCache) Roles() listersrbacv1.RoleLister { if rc == nil || !rc.isEnabled(Roles) { return nil } + if idx := rc.indexerFor(Roles); idx != nil { + return listersrbacv1.NewRoleLister(idx) + } return rc.factoryFor(Roles).Rbac().V1().Roles().Lister() } @@ -203,6 +265,9 @@ func (rc *ResourceCache) RoleBindings() listersrbacv1.RoleBindingLister { if rc == nil || !rc.isEnabled(RoleBindings) { return nil } + if idx := rc.indexerFor(RoleBindings); idx != nil { + return listersrbacv1.NewRoleBindingLister(idx) + } return rc.factoryFor(RoleBindings).Rbac().V1().RoleBindings().Lister() } @@ -217,6 +282,9 @@ func (rc *ResourceCache) LimitRanges() listerscorev1.LimitRangeLister { if rc == nil || !rc.isEnabled(LimitRanges) { return nil } + if idx := rc.indexerFor(LimitRanges); idx != nil { + return listerscorev1.NewLimitRangeLister(idx) + } return rc.factoryFor(LimitRanges).Core().V1().LimitRanges().Lister() } @@ -224,6 +292,9 @@ func (rc *ResourceCache) ResourceQuotas() listerscorev1.ResourceQuotaLister { if rc == nil || !rc.isEnabled(ResourceQuotas) { return nil } + if idx := rc.indexerFor(ResourceQuotas); idx != nil { + return listerscorev1.NewResourceQuotaLister(idx) + } return rc.factoryFor(ResourceQuotas).Core().V1().ResourceQuotas().Lister() } diff --git a/pkg/k8score/types.go b/pkg/k8score/types.go index c1e55419b..ed20ddd5a 100644 --- a/pkg/k8score/types.go +++ b/pkg/k8score/types.go @@ -119,6 +119,13 @@ type CacheConfig struct { // scope it actually has, instead of falling back to all-or-nothing. ResourceScopes map[string]ResourceScope + // ResourceScopeNamespaces optionally expands a namespace-scoped + // ResourceScopes entry to multiple namespaces. It is keyed by the same + // resource type strings as ResourceScopes. A kind with cluster-wide scope + // ignores this field; a namespaced kind with multiple entries starts one + // informer per namespace and exposes a union lister. + ResourceScopeNamespaces map[string][]string + // DeferredTypes lists resource types whose informers sync in the // background after critical informers complete. Their listers return // nil until sync finishes. If nil, no resources are deferred.