diff --git a/cmd/desktop/main.go b/cmd/desktop/main.go index 6fbc408df..f2c34a405 100644 --- a/cmd/desktop/main.go +++ b/cmd/desktop/main.go @@ -145,32 +145,37 @@ func main() { } cfg := app.AppConfig{ - Kubeconfig: *kubeconfig, - KubeconfigDirs: app.ParseKubeconfigDirs(*kubeconfigDir), - Namespace: resolvedNamespace, - Namespaces: resolvedNamespaces, - Port: fileCfg.PortOr(0), // Configured port, or random to avoid conflicts with CLI - ListenAddress: "127.0.0.1", - DevMode: false, - HistoryLimit: *historyLimit, - DebugEvents: *debugEvents, - FakeInCluster: *fakeInCluster, - DisableHelmWrite: *disableHelmWrite, - DisableExec: *disableExec, - PodShellDefault: *podShellDefault, - TimelineStorage: *timelineStorage, - TimelineDBPath: *timelineDBPath, - TimelineRetention: *timelineRetention, - TimelineMaxSizeBytes: timelineMaxSizeBytes, - PrometheusURL: *prometheusURL, - PrometheusHeaders: resolvedPrometheusHeaders, - PrometheusHeadersFromEnv: fileCfg.PrometheusHeadersFromEnv, - Version: version, - HubAPIURL: hubAPIURL, - HubAppURL: hubAppURL, - MCPEnabled: fileCfg.MCPEnabledOr(true), - AIHistory: fileCfg.AIHistoryOr(true), - AIHistoryDBPath: fileCfg.AIHistoryDBPath, + Kubeconfig: *kubeconfig, + KubeconfigDirs: app.ParseKubeconfigDirs(*kubeconfigDir), + RestoreLastDesktopContext: fileCfg.RestoreLastDesktopContextOr(true), + Namespace: resolvedNamespace, + Namespaces: resolvedNamespaces, + Port: fileCfg.PortOr(0), // Configured port, or random to avoid conflicts with CLI + ListenAddress: "127.0.0.1", + DevMode: false, + HistoryLimit: *historyLimit, + DebugEvents: *debugEvents, + FakeInCluster: *fakeInCluster, + DisableHelmWrite: *disableHelmWrite, + DisableExec: *disableExec, + PodShellDefault: *podShellDefault, + TimelineStorage: *timelineStorage, + TimelineDBPath: *timelineDBPath, + TimelineRetention: *timelineRetention, + TimelineMaxSizeBytes: timelineMaxSizeBytes, + PrometheusURL: *prometheusURL, + PrometheusHeaders: resolvedPrometheusHeaders, + PrometheusHeadersFromEnv: fileCfg.PrometheusHeadersFromEnv, + Version: version, + HubAPIURL: hubAPIURL, + HubAppURL: hubAppURL, + MCPEnabled: fileCfg.MCPEnabledOr(true), + AIHistory: fileCfg.AIHistoryOr(true), + AIHistoryDBPath: fileCfg.AIHistoryDBPath, + } + + if !cfg.RestoreLastDesktopContext { + app.ForgetLastContext() } app.SetGlobals(cfg) diff --git a/docs/configuration.md b/docs/configuration.md index 91b29a7e4..c58568283 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -58,6 +58,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`) | +| `restoreLastDesktopContext` | Desktop app only: reopen on the cluster last used (default: enabled). `false` always opens on the kubeconfig's `current-context` — see [Startup Context](#startup-context) | | `namespace` | Initial namespace filter | | `namespaces` | Initial namespace filters as a list (same as `--namespaces ns1,ns2,ns3`) | | `port` | Server port (default 9280) | @@ -93,6 +94,7 @@ User preferences for the UI. Managed via the Settings dialog or `PUT /api/settin |-------|--------|-------------| | `theme` | `light`, `dark`, `system` | UI theme preference | | `pinnedKinds` | Array of `{name, kind, group}` | Resource kinds pinned to the sidebar | +| `lastDesktopContext` | `{name, sourceFile, inFileName}` | Written by the Desktop app for itself: the cluster its window last used, reopened on the next launch. Stripped from `/api/settings`, and never read by `kubectl radar` or the `radar` CLI — see [Startup Context](#startup-context) | ## Cluster Connection Precedence @@ -145,10 +147,38 @@ Radar supports switching between Kubernetes contexts at runtime through the UI. When running in-cluster (using the pod's service account), context switching is disabled. +Switching contexts in the UI never rewrites your kubeconfig — `kubectl` keeps pointing wherever it pointed before. + ### Expired credentials If an active context's credentials expire or are rejected, Radar disconnects cluster-backed work and retries automatically. After you re-authenticate, exec-based credentials are re-probed and static credentials are reloaded from kubeconfig on disk, so Radar can reconnect without a restart. Retries start after 30 seconds and back off to 5 minutes; a credential plugin that stops responding is retried less frequently. +## Startup Context + +Which cluster Radar comes up on depends on how you launched it. + +**The Desktop app reopens where you left off.** The context selected at startup and every successful context switch are recorded as `lastDesktopContext` in `~/.radar/settings.json`, and the next launch reconnects to it — the natural behaviour for a window you closed and reopened. + +**`kubectl radar`, `radar`, and `radar diagnose --standalone` start on the kubeconfig's `current-context`**, as `kubectl` would. A command typed right after `kubectl config use-context staging` runs against staging, and a cluster picked in the Desktop app days ago never redirects it. Terminal runs don't record switches either, so nothing you do in one moves where the Desktop app reopens. + +The separation is not a preference: the remembered cluster is written under a Desktop-scoped key that the CLI never reads, and there is no setting that opts the CLI in. + +To stop the Desktop app reopening on the last cluster, turn off **Reopen on the last used cluster** in Settings → Connection, or set in `~/.radar/config.json`: + +```json +{ + "restoreLastDesktopContext": false +} +``` + +Details worth knowing: + +- The remembered context records the kubeconfig file it came from, not just its name — the name alone is not a stable handle. With several kubeconfigs loaded, two files can define the same context name, and which one keeps the unqualified name depends on the order the files are read, so adding a file can hand that name to a different cluster. +- Radar reopens only on an exact match: the same context, in the same file. Anything else — the context renamed or deleted, the file moved or no longer loaded — opens the kubeconfig's `current-context` instead, and says so in Diagnostics. A same-named context in another file is not treated as evidence that it is the same cluster; losing the convenience costs a click, landing on the wrong cluster costs more. +- If the remembered cluster is unreachable (VPN down, for instance), Radar reports the connection failure rather than silently connecting to a different cluster. Pick another cluster from the header. +- Clusters connected through CAPI are never remembered: their kubeconfig is a temporary file that no longer exists on the next run. +- Turning the memory off takes effect on the next Desktop start: it clears the remembered cluster as well as stopping new recording, so turning it back on later starts fresh rather than reopening a cluster you stopped using months ago. + ## Namespace Picker The header has a namespace picker on the right. Pick a single namespace to focus the view, or **All namespaces** to see everything you have access to. Cluster-scoped resources (Nodes, Namespaces, PVs, StorageClasses) appear regardless of the pick if your RBAC permits them — they have no namespace to filter on. Namespace-restricted users without their own cluster-scoped RBAC won't see cluster-scoped sections at all. diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 7c511b6ff..52333b931 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -16,6 +16,7 @@ import ( "github.com/skyhook-io/radar/internal/auth" "github.com/skyhook-io/radar/internal/config" + "github.com/skyhook-io/radar/internal/errorlog" "github.com/skyhook-io/radar/internal/helm" "github.com/skyhook-io/radar/internal/k8s" mcppkg "github.com/skyhook-io/radar/internal/mcp" @@ -32,44 +33,47 @@ var clusterConnectionProbe = k8s.TestClusterConnection // AppConfig holds all parsed configuration for the Radar application. type AppConfig struct { - Kubeconfig string - KubeconfigDirs []string - Namespace string - Namespaces []string - Port int - ListenAddress string - ShowRemoteAccessHint bool - BasePath string - NoBrowser bool - Browser string - DevMode bool - HistoryLimit int - DebugEvents bool - FakeInCluster bool - DisableHelmWrite bool - DisableExec bool - DisableLocalTerminal bool - PodShellDefault string - DebugImage string - ReachabilityImage string - ListPageSize int64 - NamespaceScope bool - TimelineStorage string - TimelineDBPath string - TimelineRetention time.Duration - TimelineMaxSizeBytes int64 - PrometheusURL string - PrometheusHeaders map[string]string - PrometheusHeadersFromEnv map[string]string - BeylaJobSelector string - Version string - MCPEnabled bool - AIHistory bool // persist AI investigations across restarts - AIHistoryDBPath string // "" = ~/.radar/ai-runs.db - AuthConfig auth.Config - HubAPIURL string // Hub API origin override ("" = hosted default) - HubAppURL string // Hub frontend origin override ("" = derived) - CloudTunnelConfigured bool // --cloud-url was set on this process + Kubeconfig string + KubeconfigDirs []string + // Zero value is the deliberate one: an entrypoint that never sets this + // starts on the kubeconfig's current-context. Only cmd/desktop opts in. + RestoreLastDesktopContext bool + Namespace string + Namespaces []string + Port int + ListenAddress string + ShowRemoteAccessHint bool + BasePath string + NoBrowser bool + Browser string + DevMode bool + HistoryLimit int + DebugEvents bool + FakeInCluster bool + DisableHelmWrite bool + DisableExec bool + DisableLocalTerminal bool + PodShellDefault string + DebugImage string + ReachabilityImage string + ListPageSize int64 + NamespaceScope bool + TimelineStorage string + TimelineDBPath string + TimelineRetention time.Duration + TimelineMaxSizeBytes int64 + PrometheusURL string + PrometheusHeaders map[string]string + PrometheusHeadersFromEnv map[string]string + BeylaJobSelector string + Version string + MCPEnabled bool + AIHistory bool // persist AI investigations across restarts + AIHistoryDBPath string // "" = ~/.radar/ai-runs.db + AuthConfig auth.Config + HubAPIURL string // Hub API origin override ("" = hosted default) + HubAppURL string // Hub frontend origin override ("" = derived) + CloudTunnelConfigured bool // --cloud-url was set on this process } // SetGlobals applies debug/test flags to global state. @@ -106,9 +110,16 @@ func validateNamespaceFanout(namespaces []string, ctxNs string, maxCandidates in // InitializeK8s creates and configures the Kubernetes client. func InitializeK8s(cfg AppConfig) error { - err := k8s.Initialize(k8s.InitOptions{ - KubeconfigPath: cfg.Kubeconfig, - KubeconfigDirs: cfg.KubeconfigDirs, + preferredContext, err := startupContextPreference(cfg) + if err != nil { + log.Printf("[context] failed to read the remembered Desktop context: %v", err) + errorlog.Record("k8s-init", "warning", + "could not read the Desktop cluster memory from local settings. Starting on the kubeconfig's current-context instead; check ~/.radar/settings.json.") + } + err = k8s.Initialize(k8s.InitOptions{ + KubeconfigPath: cfg.Kubeconfig, + KubeconfigDirs: cfg.KubeconfigDirs, + PreferredContext: preferredContext, }) if err != nil { return fmt.Errorf("failed to initialize K8s client: %w", err) @@ -219,6 +230,8 @@ func BuildTimelineStoreConfig(cfg AppConfig) timeline.StoreConfig { func RegisterCallbacks(cfg AppConfig, timelineStoreCfg timeline.StoreConfig) { k8s.RegisterHelmFuncs(helm.ResetClient, helm.ReinitClient) + RegisterLastContextMemory(cfg) + k8s.RegisterTimelineFuncs(func() { // Reset the store AND the per-cluster event-pipeline metrics: RecentDrops // name resources from the previous cluster and must not survive the switch. @@ -262,24 +275,26 @@ func RegisterCallbacks(cfg AppConfig, timelineStoreCfg timeline.StoreConfig) { // CreateServer creates the HTTP server with the given configuration. func CreateServer(cfg AppConfig) *server.Server { + restoreLastDesktopContext := remembersLastContext(cfg) effectiveCfg := &config.Config{ - Kubeconfig: cfg.Kubeconfig, - KubeconfigDirs: cfg.KubeconfigDirs, - Namespace: cfg.Namespace, - Namespaces: cfg.Namespaces, - Port: cfg.Port, - NoBrowser: cfg.NoBrowser, - Browser: cfg.Browser, - TimelineStorage: cfg.TimelineStorage, - TimelineDBPath: cfg.TimelineDBPath, - TimelineMaxSize: fmt.Sprintf("%d", cfg.TimelineMaxSizeBytes), - HistoryLimit: cfg.HistoryLimit, - PrometheusURL: cfg.PrometheusURL, - PrometheusHeaders: cfg.PrometheusHeaders, - PrometheusHeadersFromEnv: cfg.PrometheusHeadersFromEnv, - DebugImage: cfg.DebugImage, - ReachabilityImage: cfg.ReachabilityImage, - MCP: &cfg.MCPEnabled, + Kubeconfig: cfg.Kubeconfig, + KubeconfigDirs: cfg.KubeconfigDirs, + Namespace: cfg.Namespace, + Namespaces: cfg.Namespaces, + Port: cfg.Port, + NoBrowser: cfg.NoBrowser, + Browser: cfg.Browser, + TimelineStorage: cfg.TimelineStorage, + TimelineDBPath: cfg.TimelineDBPath, + TimelineMaxSize: fmt.Sprintf("%d", cfg.TimelineMaxSizeBytes), + HistoryLimit: cfg.HistoryLimit, + PrometheusURL: cfg.PrometheusURL, + PrometheusHeaders: cfg.PrometheusHeaders, + PrometheusHeadersFromEnv: cfg.PrometheusHeadersFromEnv, + DebugImage: cfg.DebugImage, + ReachabilityImage: cfg.ReachabilityImage, + MCP: &cfg.MCPEnabled, + RestoreLastDesktopContext: &restoreLastDesktopContext, } serverCfg := server.Config{ diff --git a/internal/app/context_scope_test.go b/internal/app/context_scope_test.go new file mode 100644 index 000000000..3c1b3406d --- /dev/null +++ b/internal/app/context_scope_test.go @@ -0,0 +1,95 @@ +package app + +import ( + "testing" + + "github.com/skyhook-io/radar/internal/settings" +) + +// The zero value is what a terminal entrypoint passes: `kubectl radar` and +// `radar diagnose --standalone` start where the shell says they do, so a +// switch made in Desktop can't steer a command typed after +// `kubectl config use-context`. +func TestTerminalEntrypointDoesNotRememberTheContext(t *testing.T) { + useTempHome(t) + + persistLastContext(AppConfig{}, "prod-eu") + + if saved := rememberedName(); saved != "" { + t.Errorf("remembered context = %q, want empty for an entrypoint that doesn't opt in", saved) + } +} + +func TestTerminalEntrypointStartsOnCurrentContext(t *testing.T) { + useTempHome(t) + remember(t, "prod-eu") + + got, err := startupContextPreference(AppConfig{}) + if err != nil { + t.Fatal(err) + } + if got.Name != "" { + t.Errorf("startupContextPreference() = %q, want empty so current-context stands", got.Name) + } +} + +func TestPersistLastContextSkippedWhenRestoreDisabled(t *testing.T) { + useTempHome(t) + + cfg := remembering() + cfg.RestoreLastDesktopContext = false + persistLastContext(cfg, "prod-eu") + + if saved := rememberedName(); saved != "" { + t.Errorf("remembered context = %q, want empty when restore is turned off", saved) + } +} + +func TestStartupContextPreferenceSkippedWhenRestoreDisabled(t *testing.T) { + useTempHome(t) + remember(t, "prod-eu") + + cfg := remembering() + cfg.RestoreLastDesktopContext = false + got, err := startupContextPreference(cfg) + if err != nil { + t.Fatal(err) + } + if got.Name != "" { + t.Errorf("startupContextPreference() = %q, want empty when restore is turned off", got.Name) + } +} + +func TestForgetLastContextClearsTheMemory(t *testing.T) { + useTempHome(t) + remember(t, "prod-eu") + + ForgetLastContext() + + if saved := rememberedName(); saved != "" { + t.Errorf("remembered context = %q, want it cleared", saved) + } +} + +// It shares settings.json with every other preference — clearing it must +// leave the rest untouched. +func TestForgetLastContextLeavesOtherSettingsAlone(t *testing.T) { + useTempHome(t) + if _, err := settings.Update(func(st *settings.Settings) { + st.Theme = "dark" + st.HelmOCISources = []string{"oci://ghcr.io/acme/charts"} + }); err != nil { + t.Fatalf("seed settings: %v", err) + } + remember(t, "prod-eu") + + ForgetLastContext() + + got := settings.Load() + if got.LastDesktopContext != nil { + t.Errorf("remembered context = %+v, want it cleared", got.LastDesktopContext) + } + if got.Theme != "dark" || len(got.HelmOCISources) != 1 { + t.Errorf("ForgetLastContext disturbed sibling settings: %+v", got) + } +} diff --git a/internal/app/last_context.go b/internal/app/last_context.go new file mode 100644 index 000000000..63822615c --- /dev/null +++ b/internal/app/last_context.go @@ -0,0 +1,123 @@ +package app + +import ( + "log" + + "github.com/skyhook-io/radar/internal/k8s" + "github.com/skyhook-io/radar/internal/settings" +) + +// remembersLastContext reports whether this process may record the active +// cluster and start on it next time. Only cmd/desktop opts in. +// +// The auth and cloud-tunnel checks cannot fire today — Desktop configures +// neither. They stand guard on the invariant: the remembered cluster is one +// user's pick, so a Desktop serving several viewers must not record it. +func remembersLastContext(cfg AppConfig) bool { + return cfg.RestoreLastDesktopContext && !cfg.AuthConfig.Enabled() && !cfg.CloudTunnelConfigured +} + +// startupContextPreference resolves which context this run starts on: the one +// the last session ended on, or an empty ref to keep the kubeconfig's +// current-context. +func startupContextPreference(cfg AppConfig) (k8s.ContextRef, error) { + if !remembersLastContext(cfg) { + return k8s.ContextRef{}, nil + } + current, err := settings.LoadChecked() + if err != nil { + return k8s.ContextRef{}, err + } + saved := current.LastDesktopContext + if saved == nil { + return k8s.ContextRef{}, nil + } + return k8s.ContextRef{ + Name: saved.Name, + SourceFile: saved.SourceFile, + InFileName: saved.InFileName, + }, nil +} + +// RegisterLastContextMemory records the initially selected context and every +// successful switch so the next start comes back on the cluster the user was +// working in. Recording selections rather than the exit is deliberate — a +// force-quit or crash would otherwise lose the pick. +func RegisterLastContextMemory(cfg AppConfig) { + if !remembersLastContext(cfg) { + return + } + + preferred, err := startupContextPreference(cfg) + current := k8s.ContextSourceFor(k8s.GetContextName()) + matchedPreference := !preferred.Empty() && preferred.SourceFile == current.SourceFile && preferred.InFileName == current.InFileName + knownMissingPreference := err == nil && !preferred.Empty() && !matchedPreference && k8s.ContextReferenceKnownMissing(preferred) + if err == nil && (preferred.Empty() || knownMissingPreference) { + persistLastContext(cfg, current.Name) + } + + protected := k8s.ContextRef{} + if err != nil || (!preferred.Empty() && !matchedPreference && !knownMissingPreference) { + protected = current + } + k8s.OnContextSwitch(lastContextSwitchRecorder(cfg, protected)) +} + +func lastContextSwitchRecorder(cfg AppConfig, protected k8s.ContextRef) k8s.ContextSwitchCallback { + return func(name string) { + candidate := k8s.ContextSourceFor(name) + if !protected.Empty() && candidate.SourceFile == protected.SourceFile && candidate.InFileName == protected.InFileName { + return + } + if persistLastContext(cfg, name) { + protected = k8s.ContextRef{} + } + } +} + +// ForgetLastContext drops the remembered cluster, so turning the memory off and +// back on later doesn't reopen a cluster the user stopped using long ago. +func ForgetLastContext() { + current, err := settings.LoadChecked() + if err != nil { + log.Printf("[context] failed to read settings before clearing the remembered context: %v", err) + return + } + if current.LastDesktopContext == nil { + return + } + if _, err := settings.UpdateChecked(func(st *settings.Settings) { + st.LastDesktopContext = nil + }); err != nil { + log.Printf("[context] failed to clear the remembered context: %v", err) + } +} + +func persistLastContext(cfg AppConfig, name string) bool { + if name == "" || !remembersLastContext(cfg) { + return false + } + // A CAPI workload cluster lives in a temp kubeconfig that's gone next run, + // so its context could never be restored. + if k8s.IsEphemeralContext(name) { + return false + } + // Record the file too: across multiple kubeconfigs the display name alone + // can be reassigned to another file's context between runs. + ref := k8s.ContextSourceFor(name) + if ref.Empty() { + log.Printf("[context] not remembering context %q because its kubeconfig source could not be resolved", name) + return false + } + if _, err := settings.UpdateChecked(func(st *settings.Settings) { + st.LastDesktopContext = &settings.LastContext{ + Name: ref.Name, + SourceFile: ref.SourceFile, + InFileName: ref.InFileName, + } + }); err != nil { + log.Printf("[context] failed to remember last used context %q: %v", name, err) + return false + } + return true +} diff --git a/internal/app/last_context_test.go b/internal/app/last_context_test.go new file mode 100644 index 000000000..43c977a25 --- /dev/null +++ b/internal/app/last_context_test.go @@ -0,0 +1,307 @@ +package app + +import ( + "os" + "path/filepath" + "testing" + + "github.com/skyhook-io/radar/internal/auth" + "github.com/skyhook-io/radar/internal/k8s" + "github.com/skyhook-io/radar/internal/settings" +) + +func useTempHome(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) + return dir +} + +func TestPersistLastContextRemembersTheSwitch(t *testing.T) { + useTempHome(t) + t.Cleanup(k8s.SetTestRegistryEntry("prod-eu", filepath.Join(t.TempDir(), "config"), "prod-eu")) + + persistLastContext(remembering(), "prod-eu") + + if saved := rememberedName(); saved != "prod-eu" { + t.Errorf("remembered context = %q, want %q", saved, "prod-eu") + } +} + +func TestRegisterLastContextMemoryRemembersInitialContext(t *testing.T) { + useTempHome(t) + k8s.ResetTestState() + t.Cleanup(k8s.ResetTestState) + path := filepath.Join(t.TempDir(), "config") + previous := k8s.SetTestContextName("prod-eu") + t.Cleanup(func() { k8s.SetTestContextName(previous) }) + t.Cleanup(k8s.SetTestRegistryEntry("prod-eu", path, "prod-eu")) + + RegisterLastContextMemory(remembering()) + + saved := settings.Load().LastDesktopContext + if saved == nil || saved.Name != "prod-eu" || saved.SourceFile != path || saved.InFileName != "prod-eu" { + t.Errorf("initial context was not remembered precisely: %+v", saved) + } +} + +func TestRegisterLastContextMemoryPreservesPreferenceThatMissed(t *testing.T) { + useTempHome(t) + k8s.ResetTestState() + t.Cleanup(k8s.ResetTestState) + if _, err := settings.Update(func(st *settings.Settings) { + st.LastDesktopContext = &settings.LastContext{ + Name: "prod-eu", + SourceFile: "/configs/old.yaml", + InFileName: "prod-eu", + } + }); err != nil { + t.Fatal(err) + } + previous := k8s.SetTestContextName("staging") + t.Cleanup(func() { k8s.SetTestContextName(previous) }) + t.Cleanup(k8s.SetTestRegistryEntry("staging", "/configs/current.yaml", "staging")) + + RegisterLastContextMemory(remembering()) + + saved := settings.Load().LastDesktopContext + if saved == nil || saved.Name != "prod-eu" || saved.SourceFile != "/configs/old.yaml" { + t.Errorf("fallback context replaced the prior preference: %+v", saved) + } +} + +func TestLastContextSwitchRecorderPreservesMissedPreferenceAcrossReconnect(t *testing.T) { + useTempHome(t) + if _, err := settings.Update(func(st *settings.Settings) { + st.LastDesktopContext = &settings.LastContext{ + Name: "prod-eu", + SourceFile: "/configs/old.yaml", + InFileName: "prod-eu", + } + }); err != nil { + t.Fatal(err) + } + t.Cleanup(k8s.SetTestRegistryEntry("staging", "/configs/current.yaml", "staging")) + + recordSwitch := lastContextSwitchRecorder(remembering(), k8s.ContextSourceFor("staging")) + recordSwitch("staging") + + saved := settings.Load().LastDesktopContext + if saved == nil || saved.Name != "prod-eu" || saved.SourceFile != "/configs/old.yaml" { + t.Errorf("reconnect to the fallback replaced the prior preference: %+v", saved) + } +} + +func TestLastContextSwitchRecorderRecordsADifferentDurableContext(t *testing.T) { + useTempHome(t) + t.Cleanup(k8s.SetTestRegistryEntry("staging", "/configs/current.yaml", "staging")) + t.Cleanup(k8s.SetTestRegistryEntry("prod-us", "/configs/prod.yaml", "prod-us")) + + recordSwitch := lastContextSwitchRecorder(remembering(), k8s.ContextSourceFor("staging")) + recordSwitch("prod-us") + + saved := settings.Load().LastDesktopContext + if saved == nil || saved.Name != "prod-us" || saved.SourceFile != "/configs/prod.yaml" { + t.Errorf("new durable selection was not remembered: %+v", saved) + } +} + +func TestPersistLastContextIgnoresEmptyName(t *testing.T) { + useTempHome(t) + + persistLastContext(remembering(), "") + + if saved := rememberedName(); saved != "" { + t.Errorf("remembered context = %q, want empty for an empty context name", saved) + } +} + +func TestPersistLastContextSkipsUnresolvableReference(t *testing.T) { + useTempHome(t) + previous := k8s.SetTestContextName("prod-eu") + t.Cleanup(func() { k8s.SetTestContextName(previous) }) + t.Cleanup(k8s.SetTestRegistryEntry("prod-eu", "", "")) + + persistLastContext(remembering(), "prod-eu") + + if saved := settings.Load().LastDesktopContext; saved != nil { + t.Errorf("unresolvable context was remembered: %+v", saved) + } +} + +func TestPersistLastContextPreservesInvalidSettings(t *testing.T) { + dir := useTempHome(t) + path := filepath.Join(dir, ".radar", "settings.json") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + const invalid = "{not-json" + if err := os.WriteFile(path, []byte(invalid), 0o644); err != nil { + t.Fatal(err) + } + kubeconfig := filepath.Join(t.TempDir(), "config") + t.Cleanup(k8s.SetTestRegistryEntry("prod-eu", kubeconfig, "prod-eu")) + + persistLastContext(remembering(), "prod-eu") + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != invalid { + t.Errorf("settings were overwritten: got %q, want %q", data, invalid) + } +} + +func TestForgetLastContextPreservesInvalidSettings(t *testing.T) { + dir := useTempHome(t) + path := filepath.Join(dir, ".radar", "settings.json") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + const invalid = "{not-json" + if err := os.WriteFile(path, []byte(invalid), 0o644); err != nil { + t.Fatal(err) + } + + ForgetLastContext() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != invalid { + t.Errorf("settings were overwritten: got %q, want %q", data, invalid) + } +} + +// A shared server must not remember one user's cluster pick on disk — the +// switch belongs to whoever made it, not to the machine. +func TestPersistLastContextSkippedWhenAuthEnabled(t *testing.T) { + useTempHome(t) + + persistLastContext(withAuth(auth.Config{Mode: "oidc"}), "prod-eu") + + if saved := rememberedName(); saved != "" { + t.Errorf("remembered context = %q, want empty when auth is enabled", saved) + } +} + +func TestPersistLastContextSkippedForCloudTunnel(t *testing.T) { + useTempHome(t) + + persistLastContext(cloudTunnelled(), "prod-eu") + + if saved := rememberedName(); saved != "" { + t.Errorf("remembered context = %q, want empty in cloud-tunnel mode", saved) + } +} + +func TestStartupContextPreferenceReturnsLastUsedContext(t *testing.T) { + useTempHome(t) + remember(t, "prod-eu") + + got, err := startupContextPreference(remembering()) + if err != nil { + t.Fatal(err) + } + if got.Name != "prod-eu" { + t.Errorf("startupContextPreference() = %q, want %q", got.Name, "prod-eu") + } +} + +func TestStartupContextPreferenceEmptyWithoutSavedContext(t *testing.T) { + useTempHome(t) + + got, err := startupContextPreference(remembering()) + if err != nil { + t.Fatal(err) + } + if got.Name != "" { + t.Errorf("startupContextPreference() = %q, want empty", got.Name) + } +} + +func TestStartupContextPreferenceReportsUnreadableSettings(t *testing.T) { + dir := useTempHome(t) + path := filepath.Join(dir, ".radar", "settings.json") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("{not-json"), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := startupContextPreference(remembering()); err == nil { + t.Fatal("startupContextPreference() error = nil, want unreadable settings reported") + } +} + +func TestStartupContextPreferenceSkippedWhenAuthEnabled(t *testing.T) { + useTempHome(t) + remember(t, "prod-eu") + + got, err := startupContextPreference(withAuth(auth.Config{Mode: "proxy"})) + if err != nil { + t.Fatal(err) + } + if got.Name != "" { + t.Errorf("startupContextPreference() = %q, want empty when auth is enabled", got.Name) + } +} + +// remembering returns the config of an entrypoint that opts into the memory — +// Desktop's shape. The zero AppConfig deliberately does not. +func remembering() AppConfig { + return AppConfig{RestoreLastDesktopContext: true} +} + +func withAuth(c auth.Config) AppConfig { + cfg := remembering() + cfg.AuthConfig = c + return cfg +} + +func cloudTunnelled() AppConfig { + cfg := remembering() + cfg.CloudTunnelConfigured = true + return cfg +} + +// rememberedName reads back the recorded context name, treating "nothing +// recorded" as the empty string. +func rememberedName() string { + if saved := settings.Load().LastDesktopContext; saved != nil { + return saved.Name + } + return "" +} + +func remember(t *testing.T, name string) { + t.Helper() + if _, err := settings.Update(func(st *settings.Settings) { + st.LastDesktopContext = &settings.LastContext{Name: name} + }); err != nil { + t.Fatalf("Update: %v", err) + } +} + +// The switch is recorded with the file it came from, not just the name the +// header showed — see settings.LastContext for why the name alone is not a +// stable handle across kubeconfigs. +func TestPersistLastContextRecordsWhereTheContextCameFrom(t *testing.T) { + useTempHome(t) + path := filepath.Join(t.TempDir(), "team.yaml") + t.Cleanup(k8s.SetTestRegistryEntry("dev (team)", path, "dev")) + + persistLastContext(remembering(), "dev (team)") + + saved := settings.Load().LastDesktopContext + if saved == nil { + t.Fatal("nothing recorded") + } + if got := *saved; got.Name != "dev (team)" || got.SourceFile != path || got.InFileName != "dev" { + t.Errorf("recorded %+v, want name/file/in-file-name all pinned", saved) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index c529902ac..8c7c1a427 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,19 +14,22 @@ import ( // Config holds startup configuration persisted across restarts. // Values are used as flag defaults; explicit CLI flags always take precedence. 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"` - TimelineStorage string `json:"timelineStorage,omitempty"` - TimelineDBPath string `json:"timelineDbPath,omitempty"` - TimelineRetention string `json:"timelineRetention,omitempty"` // Go duration (e.g. "168h" for 7d); "0" disables age cleanup - TimelineMaxSize string `json:"timelineMaxSize,omitempty"` // Byte size (e.g. "800Mi", "8Gi"); "0" disables - HistoryLimit int `json:"historyLimit,omitempty"` - PrometheusURL string `json:"prometheusUrl,omitempty"` + Kubeconfig string `json:"kubeconfig,omitempty"` + KubeconfigDirs []string `json:"kubeconfigDirs,omitempty"` + // nil = on. No CLI equivalent, deliberately — that would put a Desktop + // switch in the path of a command typed after `kubectl config use-context`. + RestoreLastDesktopContext *bool `json:"restoreLastDesktopContext,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"` + TimelineStorage string `json:"timelineStorage,omitempty"` + TimelineDBPath string `json:"timelineDbPath,omitempty"` + TimelineRetention string `json:"timelineRetention,omitempty"` // Go duration (e.g. "168h" for 7d); "0" disables age cleanup + TimelineMaxSize string `json:"timelineMaxSize,omitempty"` // Byte size (e.g. "800Mi", "8Gi"); "0" disables + HistoryLimit int `json:"historyLimit,omitempty"` + PrometheusURL string `json:"prometheusUrl,omitempty"` // PrometheusHeaders are sent with every request to the Prometheus API. // Required for auth-protected backends (Bearer tokens, X-Scope-OrgID, etc.). // Stored in plain text in ~/.radar/config.json — protect the file accordingly. @@ -205,6 +208,15 @@ func (c Config) MCPEnabledOr(def bool) bool { return def } +// RestoreLastDesktopContextOr returns *c.RestoreLastDesktopContext if non-nil, otherwise the +// provided default. +func (c Config) RestoreLastDesktopContextOr(def bool) bool { + if c.RestoreLastDesktopContext != nil { + return *c.RestoreLastDesktopContext + } + return def +} + // AIHistoryOr returns *c.AIHistory if non-nil, otherwise the provided default. func (c Config) AIHistoryOr(def bool) bool { if c.AIHistory != nil { diff --git a/internal/k8s/client.go b/internal/k8s/client.go index 2f341a02f..d420e3d49 100644 --- a/internal/k8s/client.go +++ b/internal/k8s/client.go @@ -105,6 +105,10 @@ func SetEnrichedKubeconfigFromShell(v bool) { type InitOptions struct { KubeconfigPath string KubeconfigDirs []string // Directories containing kubeconfig files + // PreferredContext is the context to start on instead of the kubeconfig's + // current-context. Ignored when it doesn't resolve, so a stale preference + // can never keep Radar from starting. + PreferredContext ContextRef } // Initialize initializes the K8s client with the given options @@ -143,6 +147,9 @@ func doInit(opts InitOptions) error { contextName = "in-cluster" clusterName = "in-cluster" kubeconfigMode = "in-cluster" + if !opts.PreferredContext.Empty() { + log.Printf("[k8s-init] ignoring preferred context %q: running in-cluster", opts.PreferredContext.Name) + } } } @@ -166,8 +173,9 @@ func doInit(opts InitOptions) error { kubeconfigMode = "multi-dir" if len(configs) == 1 { loadingRules = &clientcmd.ClientConfigLoadingRules{ExplicitPath: configs[0]} + applyContextPreference(configs[0], opts.PreferredContext, configOverrides) } else { - lr, ovr, err := setupIsolatedLoad(configs) + lr, ovr, err := setupIsolatedLoad(configs, opts.PreferredContext) if err != nil { return err } @@ -192,7 +200,7 @@ func doInit(opts InitOptions) error { if paths := filepath.SplitList(kubeconfig); len(paths) > 1 { kubeconfigPaths = paths kubeconfigMode = "multi-env" - lr, ovr, err := setupIsolatedLoad(paths) + lr, ovr, err := setupIsolatedLoad(paths, opts.PreferredContext) if err != nil { return err } @@ -202,6 +210,7 @@ func doInit(opts InitOptions) error { kubeconfigPath = kubeconfig kubeconfigMode = "single" loadingRules = &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfig} + applyContextPreference(kubeconfig, opts.PreferredContext, configOverrides) } } @@ -249,6 +258,9 @@ func doInit(opts InitOptions) error { } } else { contextName = rawConfig.CurrentContext + if configOverrides.CurrentContext != "" { + contextName = configOverrides.CurrentContext + } totalContextCount = len(rawConfig.Contexts) cmds, emptyAIs := collectExecPluginCommands(&rawConfig) execPluginCommands = cmds diff --git a/internal/k8s/context_path_identity_test.go b/internal/k8s/context_path_identity_test.go new file mode 100644 index 000000000..f9dfa273c --- /dev/null +++ b/internal/k8s/context_path_identity_test.go @@ -0,0 +1,90 @@ +package k8s + +import ( + "path/filepath" + "testing" + + "k8s.io/client-go/tools/clientcmd" +) + +// `--kubeconfig-dir ./configs` puts a relative path in the registry, so the +// recorded ref and the live entry spell the same file two different ways. +func TestPreferredContextMatchesAbsoluteRefAgainstRelativeRegistryPath(t *testing.T) { + home := t.TempDir() + t.Chdir(home) + + registry := map[string]contextEntry{ + "alpha": {SourceFile: filepath.Join("configs", "a.yaml"), InFileName: "alpha"}, + } + recorded := ContextRef{ + Name: "alpha", + SourceFile: filepath.Join(home, "configs", "a.yaml"), // as ContextSourceFor stores it + InFileName: "alpha", + } + + if _, _, ok := matchPreferred(registry, recorded); !ok { + t.Error("a ref recorded as an absolute path did not match the same file held relatively") + } +} + +func TestPreferredContextDoesNotResolveRelativePathFromAnotherWorkingDirectory(t *testing.T) { + recordedFrom := t.TempDir() + loadedFrom := t.TempDir() + recorded := ContextRef{ + Name: "alpha", + SourceFile: filepath.Join(recordedFrom, "configs", "a.yaml"), + InFileName: "alpha", + } + + t.Chdir(loadedFrom) + registry := map[string]contextEntry{ + "alpha": {SourceFile: filepath.Join("configs", "a.yaml"), InFileName: "alpha"}, + } + + if _, _, ok := matchPreferred(registry, recorded); ok { + t.Error("a relative path from another working directory matched a different file") + } +} + +// Recording resolved is what stops the ambiguity: a path stored relatively +// would compare equal to a different file in another directory, and nothing +// can disambiguate it after the fact — the cwd that produced it is gone. +func TestContextSourceForRecordsAResolvedPath(t *testing.T) { + home := t.TempDir() + t.Chdir(home) + t.Cleanup(SetTestRegistryEntry("alpha", filepath.Join("configs", "a.yaml"), "alpha")) + + got := ContextSourceFor("alpha") + + if !filepath.IsAbs(got.SourceFile) { + t.Errorf("recorded SourceFile = %q, want an absolute path", got.SourceFile) + } + if want := filepath.Join(home, "configs", "a.yaml"); got.SourceFile != want { + t.Errorf("recorded SourceFile = %q, want %q", got.SourceFile, want) + } +} + +// Same property on the single-kubeconfig path, which compares against the file +// doInit is about to load rather than against a registry. +func TestApplyContextPreferenceComparesResolvedPaths(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + writeKubeconfig(t, dir, "config", "alpha", []kubeEntry{ + {ctxName: "alpha", userName: "ua", clusterName: "cluster-alpha"}, + {ctxName: "beta", userName: "ub", clusterName: "cluster-beta"}, + }) + + recorded := ContextRef{ + Name: "beta", + SourceFile: filepath.Join(dir, "config"), // absolute, as recorded + InFileName: "beta", + } + + overrides := &clientcmd.ConfigOverrides{} + applyContextPreference("config", recorded, overrides) // relative, as loaded + + if overrides.CurrentContext != "beta" { + t.Errorf("CurrentContext = %q, want %q: the same file spelled two ways did not match", + overrides.CurrentContext, "beta") + } +} diff --git a/internal/k8s/context_preference_test.go b/internal/k8s/context_preference_test.go new file mode 100644 index 000000000..50460dcaa --- /dev/null +++ b/internal/k8s/context_preference_test.go @@ -0,0 +1,121 @@ +package k8s + +import ( + "path/filepath" + "testing" +) + +func TestPickInitialContextResolvesTheRecordedContext(t *testing.T) { + dir := t.TempDir() + f1 := writeKubeconfig(t, dir, "first.yaml", "from-first", []kubeEntry{ + {ctxName: "from-first", userName: "u1", clusterName: "c1"}, + }) + f2 := writeKubeconfig(t, dir, "second.yaml", "from-second", []kubeEntry{ + {ctxName: "from-second", userName: "u2", clusterName: "c2"}, + }) + + paths := []string{f1, f2} + registry, fileConfigs := buildContextRegistry(paths) + saved := ContextRef{Name: "from-second", SourceFile: f2, InFileName: "from-second"} + qName, entry, ok := pickInitialContext(paths, registry, fileConfigs, saved) + if !ok { + t.Fatal("pickInitialContext() found no context") + } + if qName != "from-second" { + t.Errorf("qName = %q, want the recorded context %q", qName, "from-second") + } + if entry.SourceFile != f2 { + t.Errorf("entry.SourceFile = %q, want %q", entry.SourceFile, f2) + } +} + +func TestPickInitialContextIgnoresAContextThatIsGone(t *testing.T) { + dir := t.TempDir() + f1 := writeKubeconfig(t, dir, "first.yaml", "from-first", []kubeEntry{ + {ctxName: "from-first", userName: "u1", clusterName: "c1"}, + }) + + paths := []string{f1} + registry, fileConfigs := buildContextRegistry(paths) + saved := ContextRef{Name: "ghost", SourceFile: f1, InFileName: "ghost"} + qName, _, ok := pickInitialContext(paths, registry, fileConfigs, saved) + if !ok { + t.Fatal("pickInitialContext() found no context") + } + if qName != "from-first" { + t.Errorf("qName = %q, want the current-context fallback %q", qName, "from-first") + } +} + +// The scenario the source file exists for: two kubeconfigs define "dev", and a +// file added later takes over the unqualified name. Resolving by name alone +// would connect to the newcomer's cluster under the name the user last used. +func TestPickInitialContextPrefersTheRecordedSourceFileOverTheName(t *testing.T) { + dir := t.TempDir() + newcomer := writeKubeconfig(t, dir, "aaa.yaml", "dev", []kubeEntry{ + {ctxName: "dev", userName: "u1", clusterName: "someone-elses-cluster"}, + }) + worked := writeKubeconfig(t, dir, "bbb.yaml", "dev", []kubeEntry{ + {ctxName: "dev", userName: "u2", clusterName: "the-cluster-i-was-on"}, + }) + + paths := []string{newcomer, worked} + registry, fileConfigs := buildContextRegistry(paths) + if entry := registry["dev"]; entry.SourceFile != newcomer { + t.Fatalf("precondition: expected %q to own the unqualified name, got %q", newcomer, entry.SourceFile) + } + + saved := ContextRef{Name: "dev", SourceFile: worked, InFileName: "dev"} + _, entry, ok := pickInitialContext(paths, registry, fileConfigs, saved) + if !ok { + t.Fatal("pickInitialContext() found no context") + } + if entry.SourceFile != worked { + t.Errorf("entry.SourceFile = %q, want the recorded file %q — the name was reassigned", entry.SourceFile, worked) + } +} + +// Once the recorded file is gone, a same-named context in another file is not +// evidence that it is the same cluster. Radar opens current-context instead of +// guessing — losing the convenience beats landing somewhere the user didn't pick. +func TestPickInitialContextDoesNotAdoptASameNamedContextFromAnotherFile(t *testing.T) { + dir := t.TempDir() + current := writeKubeconfig(t, dir, "first.yaml", "from-first", []kubeEntry{ + {ctxName: "from-first", userName: "u1", clusterName: "c1"}, + }) + impostor := writeKubeconfig(t, dir, "second.yaml", "", []kubeEntry{ + {ctxName: "prod", userName: "u2", clusterName: "someone-elses-prod"}, + }) + + paths := []string{current, impostor} + registry, fileConfigs := buildContextRegistry(paths) + saved := ContextRef{Name: "prod", SourceFile: filepath.Join(dir, "deleted.yaml"), InFileName: "prod"} + qName, entry, ok := pickInitialContext(paths, registry, fileConfigs, saved) + if !ok { + t.Fatal("pickInitialContext() found no context") + } + if qName != "from-first" || entry.SourceFile != current { + t.Errorf("resolved %q from %q, want the current-context fallback %q from %q", + qName, entry.SourceFile, "from-first", current) + } +} + +// A ref carrying only a name — nothing records one today — is not resolvable: +// the name is exactly the part another file can take over. +func TestPickInitialContextIgnoresANameOnlyReference(t *testing.T) { + dir := t.TempDir() + f1 := writeKubeconfig(t, dir, "first.yaml", "from-first", []kubeEntry{ + {ctxName: "from-first", userName: "u1", clusterName: "c1"}, + {ctxName: "other", userName: "u2", clusterName: "c2"}, + }) + + paths := []string{f1} + registry, fileConfigs := buildContextRegistry(paths) + qName, _, ok := pickInitialContext(paths, registry, fileConfigs, ContextRef{Name: "other"}) + if !ok { + t.Fatal("pickInitialContext() found no context") + } + if qName != "from-first" { + t.Errorf("qName = %q, want the current-context fallback %q", qName, "from-first") + } +} diff --git a/internal/k8s/context_registry.go b/internal/k8s/context_registry.go index dff95ded3..9e927fa0b 100644 --- a/internal/k8s/context_registry.go +++ b/internal/k8s/context_registry.go @@ -25,7 +25,7 @@ import ( // than one kubeconfig file: each file stays an island. A SwitchContext later // looks up the target entry in the registry and loads that one file, so // shared user/cluster names across files never collide — see issue #519. -func setupIsolatedLoad(paths []string) ( +func setupIsolatedLoad(paths []string, preferred ContextRef) ( *clientcmd.ClientConfigLoadingRules, *clientcmd.ConfigOverrides, error, @@ -34,7 +34,7 @@ func setupIsolatedLoad(paths []string) ( if len(registry) == 0 { return nil, nil, fmt.Errorf("no contexts found across %d kubeconfig files", len(paths)) } - qName, entry, ok := pickInitialContext(paths, registry, fileConfigs) + qName, entry, ok := pickInitialContext(paths, registry, fileConfigs, preferred) if !ok { return nil, nil, fmt.Errorf("no usable context found across %d kubeconfig files", len(paths)) } @@ -164,7 +164,15 @@ func pickInitialContext( paths []string, registry map[string]contextEntry, fileConfigs map[string]*clientcmdapi.Config, + preferred ContextRef, ) (string, contextEntry, bool) { + // Preference pass: the context the last session ended on. It resolves only + // on an exact (file, in-file name) match — see matchPreferred. + if qName, entry, ok := matchPreferred(registry, preferred); ok { + return qName, entry, true + } + reportContextPreferenceMiss(preferred) + // First pass: honor CurrentContext in file order. for _, path := range paths { cfg, ok := fileConfigs[path] @@ -195,6 +203,25 @@ func pickInitialContext( return "", contextEntry{}, false } +// matchPreferred resolves a saved reference against the registry on the exact +// (file, in-file name) pair, and nothing else. There is deliberately no +// fallback to the display name: another file may have taken that name over +// since it was recorded, so following it would connect to a different cluster +// under the name the user last used. Losing the convenience costs one click; +// landing on the wrong cluster costs more than that. +func matchPreferred(registry map[string]contextEntry, preferred ContextRef) (string, contextEntry, bool) { + if preferred.Empty() { + return "", contextEntry{}, false + } + wantFile := canonicalKubeconfigPath(preferred.SourceFile) + for qName, entry := range registry { + if canonicalKubeconfigPath(entry.SourceFile) == wantFile && entry.InFileName == preferred.InFileName { + return qName, entry, true + } + } + return "", contextEntry{}, false +} + // refreshContextRegistry reconciles the in-memory contextRegistry + // perFileConfigs against what's actually on disk RIGHT NOW. Returns // new map values (registry, fileConfigs, fileMtimes) plus a `changed` diff --git a/internal/k8s/context_registry_test.go b/internal/k8s/context_registry_test.go index f0bc4d62d..f3870c808 100644 --- a/internal/k8s/context_registry_test.go +++ b/internal/k8s/context_registry_test.go @@ -364,7 +364,7 @@ func TestPickInitialContext_PrefersFirstFileCurrentContext(t *testing.T) { paths := []string{f1, f2} registry, fileConfigs := buildContextRegistry(paths) - qName, entry, ok := pickInitialContext(paths, registry, fileConfigs) + qName, entry, ok := pickInitialContext(paths, registry, fileConfigs, ContextRef{}) if !ok { t.Fatal("expected initial context") } @@ -388,7 +388,7 @@ func TestPickInitialContext_FallsBackWhenCurrentContextEmpty(t *testing.T) { paths := []string{f1, f2} registry, fileConfigs := buildContextRegistry(paths) - qName, _, ok := pickInitialContext(paths, registry, fileConfigs) + qName, _, ok := pickInitialContext(paths, registry, fileConfigs, ContextRef{}) if !ok { t.Fatal("expected initial context") } @@ -405,7 +405,7 @@ func TestPickInitialContext_NoCurrentContextAnywhere(t *testing.T) { paths := []string{f1} registry, fileConfigs := buildContextRegistry(paths) - qName, _, ok := pickInitialContext(paths, registry, fileConfigs) + qName, _, ok := pickInitialContext(paths, registry, fileConfigs, ContextRef{}) if !ok { t.Fatal("expected initial context from any-ctx fallback") } diff --git a/internal/k8s/context_source.go b/internal/k8s/context_source.go new file mode 100644 index 000000000..a51d98e7b --- /dev/null +++ b/internal/k8s/context_source.go @@ -0,0 +1,180 @@ +package k8s + +import ( + "log" + "path/filepath" + + "k8s.io/client-go/tools/clientcmd" + + "github.com/skyhook-io/radar/internal/errorlog" +) + +// ContextRef identifies a kubeconfig context precisely enough to survive a +// restart: the name Radar displays, plus the file it came from and the name it +// carries inside that file. +// +// The name alone is ambiguous once more than one kubeconfig is loaded. Two +// files can define the same context name, and which one keeps the unqualified +// form depends on the order discoverKubeconfigs walks the directory — so +// adding a file can silently reassign the name to a different cluster. A +// caller persisting a context across restarts must carry the file too. +// +// SourceFile + InFileName are the identity; Name is the label Radar shows. A +// ref carrying only a Name resolves to nothing, because a name is exactly the +// thing another file can take over. +type ContextRef struct { + Name string + SourceFile string + InFileName string +} + +// Empty reports whether the ref names nothing to resolve. +func (r ContextRef) Empty() bool { + return r.SourceFile == "" || r.InFileName == "" +} + +// canonicalKubeconfigPath resolves a path so the same file compares equal +// across restarts: --kubeconfig-dir records relative entries, and a relative +// path can also match a *different* file reached from another directory. Abs +// rather than EvalSymlinks — the latter errors on a since-deleted file. +func canonicalKubeconfigPath(p string) string { + if p == "" { + return "" + } + abs, err := filepath.Abs(p) + if err != nil { + return filepath.Clean(p) + } + return abs +} + +// ContextSourceFor returns the full reference for a context Radar currently +// knows, so callers persisting it can record where it came from. Outside the +// registry there is only one kubeconfig loaded, and it is the only place the +// active context can have come from. +func ContextSourceFor(name string) ContextRef { + clientMu.RLock() + defer clientMu.RUnlock() + + if entry, ok := contextRegistry[name]; ok { + return ContextRef{Name: name, SourceFile: canonicalKubeconfigPath(entry.SourceFile), InFileName: entry.InFileName} + } + if name != "" && name == contextName { + if path := singleLoadedKubeconfig(); path != "" { + return ContextRef{Name: name, SourceFile: canonicalKubeconfigPath(path), InFileName: name} + } + } + return ContextRef{Name: name} +} + +// ContextReferenceKnownMissing reports whether Radar successfully loaded the +// recorded kubeconfig file and that file no longer defines the recorded +// context. A false result is intentionally inconclusive: the file may only be +// temporarily unavailable or outside this run's configured kubeconfig set. +func ContextReferenceKnownMissing(ref ContextRef) bool { + if ref.Empty() { + return false + } + + wantFile := canonicalKubeconfigPath(ref.SourceFile) + clientMu.RLock() + defer clientMu.RUnlock() + + if contextRegistry != nil { + for path, cfg := range perFileConfigs { + if canonicalKubeconfigPath(path) != wantFile { + continue + } + _, exists := cfg.Contexts[ref.InFileName] + return !exists + } + return false + } + + path := singleLoadedKubeconfig() + if path == "" || canonicalKubeconfigPath(path) != wantFile { + return false + } + cfg, err := clientcmd.LoadFromFile(path) + if err != nil { + return false + } + _, exists := cfg.Contexts[ref.InFileName] + return !exists +} + +// singleLoadedKubeconfig returns the one kubeconfig backing this process, or "" +// when several are loaded (the registry answers there) or none is (in-cluster). +// --kubeconfig-dir records its find in kubeconfigPaths even when it finds +// exactly one file, so both globals have to be consulted. Callers must hold +// clientMu. +func singleLoadedKubeconfig() string { + if kubeconfigPath != "" { + return kubeconfigPath + } + if len(kubeconfigPaths) == 1 { + return kubeconfigPaths[0] + } + return "" +} + +// IsEphemeralContext reports whether a context lives in a temp kubeconfig Radar +// wrote itself for a CAPI workload cluster. That file is gone on the next run, +// so callers that persist a context across restarts must skip those. +func IsEphemeralContext(name string) bool { + clientMu.RLock() + defer clientMu.RUnlock() + + entry, ok := contextRegistry[name] + if !ok { + return false + } + for _, tmpPath := range capiKubeconfigs { + if tmpPath == entry.SourceFile { + return true + } + } + return false +} + +// applyContextPreference points overrides at the preferred context when the +// kubeconfig at path is the file it was recorded from and still defines it. +// Validating first matters twice: a context that has since been renamed or +// deleted would otherwise fail the whole startup, and the override has to be in +// place before the deferred loader builds its inner config — it captures +// CurrentContext on the first RawConfig()/ClientConfig() call and caches it. +func applyContextPreference(path string, preferred ContextRef, overrides *clientcmd.ConfigOverrides) { + if preferred.Empty() || canonicalKubeconfigPath(preferred.SourceFile) != canonicalKubeconfigPath(path) { + reportContextPreferenceMiss(preferred) + return + } + cfg, err := clientcmd.LoadFromFile(path) + if err != nil { + reportContextPreferenceMiss(preferred) + return + } + if _, ok := cfg.Contexts[preferred.InFileName]; !ok { + reportContextPreferenceMiss(preferred) + return + } + overrides.CurrentContext = preferred.InFileName +} + +// reportContextPreferenceMiss explains why Radar did not come up where the last +// session left it. Falling back to current-context is the safe answer — the +// name alone is what another kubeconfig can take over — but a silent redirect +// leaves the user staring at a cluster they didn't pick, so it goes to the +// diagnostics surface and not only to the log. +func reportContextPreferenceMiss(preferred ContextRef) { + name := preferred.Name + if name == "" { + name = preferred.InFileName + } + if name == "" { + return + } + log.Printf("[k8s-init] last used context %q not found where it was recorded; using current-context", name) + errorlog.Record("k8s-init", "warning", + "could not reopen on %q: that context is no longer in the kubeconfig it was recorded from. Starting on the kubeconfig's current-context instead.", + name) +} diff --git a/internal/k8s/last_context_test.go b/internal/k8s/last_context_test.go new file mode 100644 index 000000000..c9852d61e --- /dev/null +++ b/internal/k8s/last_context_test.go @@ -0,0 +1,226 @@ +package k8s + +import ( + "path/filepath" + "strings" + "testing" +) + +// restoreClientGlobals snapshots the package state doInit writes and puts it +// back afterwards, so these tests can run a real init without leaking a fake +// cluster into sibling tests. +func restoreClientGlobals(t *testing.T) { + t.Helper() + clientMu.Lock() + var ( + savedPath = kubeconfigPath + savedPaths = kubeconfigPaths + savedMode = kubeconfigMode + savedRegistry = contextRegistry + savedConfigs = perFileConfigs + savedMtimes = perFileMtimes + savedContext = contextName + savedCluster = clusterName + savedNamespace = contextNamespace + savedUsesExec = contextUsesExec + savedTotal = totalContextCount + savedExecCmds = execPluginCommands + savedClient = k8sClient + savedConfig = k8sConfig + savedDiscovery = discoveryClient + savedDynamic = dynamicClient + savedGeneration = activeClientGeneration + ) + clientMu.Unlock() + + t.Cleanup(func() { + clientMu.Lock() + defer clientMu.Unlock() + kubeconfigPath = savedPath + kubeconfigPaths = savedPaths + kubeconfigMode = savedMode + contextRegistry = savedRegistry + perFileConfigs = savedConfigs + perFileMtimes = savedMtimes + contextName = savedContext + clusterName = savedCluster + contextNamespace = savedNamespace + contextUsesExec = savedUsesExec + totalContextCount = savedTotal + execPluginCommands = savedExecCmds + k8sClient = savedClient + k8sConfig = savedConfig + discoveryClient = savedDiscovery + dynamicClient = savedDynamic + activeClientGeneration = savedGeneration + }) +} + +func TestDoInitPrefersRequestedContext(t *testing.T) { + restoreClientGlobals(t) + dir := t.TempDir() + path := writeKubeconfig(t, dir, "config", "alpha", []kubeEntry{ + {ctxName: "alpha", userName: "ua", clusterName: "cluster-alpha", namespace: "ns-alpha"}, + {ctxName: "beta", userName: "ub", clusterName: "cluster-beta", namespace: "ns-beta"}, + }) + + saved := ContextRef{Name: "beta", SourceFile: path, InFileName: "beta"} + if err := doInit(InitOptions{KubeconfigPath: path, PreferredContext: saved}); err != nil { + t.Fatalf("doInit() error = %v", err) + } + + if got := GetContextName(); got != "beta" { + t.Errorf("GetContextName() = %q, want %q", got, "beta") + } + if got := GetContextNamespace(); got != "ns-beta" { + t.Errorf("GetContextNamespace() = %q, want %q", got, "ns-beta") + } + // The bookkeeping and the client must agree: a context name that says + // "beta" while the REST config still dials alpha is the failure mode + // this preference has to avoid. + if host := GetConfig().Host; !strings.Contains(host, "cluster-beta") { + t.Errorf("rest config Host = %q, want it to point at cluster-beta", host) + } + if ContextReferenceKnownMissing(saved) { + t.Error("ContextReferenceKnownMissing(saved) = true for a context that resolved") + } +} + +func TestDoInitFallsBackWhenPreferredContextMissing(t *testing.T) { + restoreClientGlobals(t) + dir := t.TempDir() + path := writeKubeconfig(t, dir, "config", "alpha", []kubeEntry{ + {ctxName: "alpha", userName: "ua", clusterName: "cluster-alpha", namespace: "ns-alpha"}, + }) + + saved := ContextRef{Name: "ghost", SourceFile: path, InFileName: "ghost"} + if err := doInit(InitOptions{KubeconfigPath: path, PreferredContext: saved}); err != nil { + t.Fatalf("doInit() error = %v", err) + } + + if got := GetContextName(); got != "alpha" { + t.Errorf("GetContextName() = %q, want the kubeconfig current-context %q", got, "alpha") + } + if host := GetConfig().Host; !strings.Contains(host, "cluster-alpha") { + t.Errorf("rest config Host = %q, want it to point at cluster-alpha", host) + } + if !ContextReferenceKnownMissing(saved) { + t.Error("ContextReferenceKnownMissing(saved) = false after the loaded file proved the context is gone") + } +} + +func TestContextReferenceKnownMissingKeepsAnUnavailableFileInconclusive(t *testing.T) { + restoreClientGlobals(t) + dir := t.TempDir() + loaded := writeKubeconfig(t, dir, "config", "alpha", []kubeEntry{ + {ctxName: "alpha", userName: "ua", clusterName: "cluster-alpha"}, + }) + if err := doInit(InitOptions{KubeconfigPath: loaded}); err != nil { + t.Fatalf("doInit() error = %v", err) + } + + ref := ContextRef{Name: "prod", SourceFile: filepath.Join(dir, "unavailable"), InFileName: "prod"} + if ContextReferenceKnownMissing(ref) { + t.Error("ContextReferenceKnownMissing(ref) = true for a file this run never loaded") + } +} + +func TestIsEphemeralContextSingleKubeconfig(t *testing.T) { + restoreClientGlobals(t) + clientMu.Lock() + kubeconfigPath = "/home/user/.kube/config" + kubeconfigPaths = nil + contextRegistry = nil + clientMu.Unlock() + + if IsEphemeralContext("prod") { + t.Error("IsEphemeralContext(prod) = true, want false for a context from the user's kubeconfig") + } +} + +func TestIsEphemeralContextReportsCAPIContext(t *testing.T) { + restoreClientGlobals(t) + dir := t.TempDir() + durable := writeKubeconfig(t, dir, "durable.yaml", "prod", []kubeEntry{ + {ctxName: "prod", userName: "u", clusterName: "c"}, + }) + temp := writeKubeconfig(t, dir, "radar-capi-kubeconfig-1234.yaml", "workload", []kubeEntry{ + {ctxName: "workload", userName: "u", clusterName: "c"}, + }) + + clientMu.Lock() + kubeconfigPath = "" + kubeconfigPaths = []string{durable, temp} + contextRegistry = map[string]contextEntry{ + "prod": {SourceFile: durable, InFileName: "prod"}, + "workload": {SourceFile: temp, InFileName: "workload"}, + } + savedCAPI := capiKubeconfigs + capiKubeconfigs = map[string]string{"workload": temp} + clientMu.Unlock() + t.Cleanup(func() { + clientMu.Lock() + capiKubeconfigs = savedCAPI + clientMu.Unlock() + }) + + if IsEphemeralContext("prod") { + t.Error("IsEphemeralContext(prod) = true for a durable kubeconfig") + } + if !IsEphemeralContext("workload") { + t.Error("IsEphemeralContext(workload) = false for a CAPI temp kubeconfig") + } +} + +// --kubeconfig-dir records its find in kubeconfigPaths even when it discovers +// exactly one file, and builds no registry because there is nothing to +// disambiguate. Without consulting both globals the source file would never be +// recorded, and a restore that requires an exact match could never resolve. +func TestContextSourceForRecordsTheFileFoundInAKubeconfigDir(t *testing.T) { + restoreClientGlobals(t) + clientMu.Lock() + kubeconfigPath = "" + kubeconfigPaths = []string{"/home/user/.kube/configs/prod.yaml"} + contextRegistry = nil + contextName = "prod" + clientMu.Unlock() + + got := ContextSourceFor("prod") + if got.SourceFile != "/home/user/.kube/configs/prod.yaml" || got.InFileName != "prod" { + t.Errorf("ContextSourceFor(prod) = %+v, want the discovered file recorded", got) + } + if got.Empty() { + t.Error("ref is not resolvable, so the memory could never be restored") + } +} + +func TestContextSourceForRecordsTheSingleKubeconfig(t *testing.T) { + restoreClientGlobals(t) + clientMu.Lock() + kubeconfigPath = "/home/user/.kube/config" + kubeconfigPaths = nil + contextRegistry = nil + contextName = "prod" + clientMu.Unlock() + + got := ContextSourceFor("prod") + if got.SourceFile != "/home/user/.kube/config" || got.InFileName != "prod" { + t.Errorf("ContextSourceFor(prod) = %+v, want the loaded kubeconfig recorded", got) + } +} + +// Several files loaded means the registry is the only thing that knows which +// one a context came from — no single-file guess applies. +func TestContextSourceForLeavesNoFileWhenSeveralAreLoaded(t *testing.T) { + restoreClientGlobals(t) + clientMu.Lock() + kubeconfigPath = "" + kubeconfigPaths = []string{"/a.yaml", "/b.yaml"} + contextRegistry = map[string]contextEntry{} + contextName = "prod" + clientMu.Unlock() + + if got := ContextSourceFor("prod"); !got.Empty() { + t.Errorf("ContextSourceFor(prod) = %+v, want an unresolvable ref rather than a guess", got) + } +} diff --git a/internal/k8s/testing.go b/internal/k8s/testing.go index 3d1c6074c..a96a68df9 100644 --- a/internal/k8s/testing.go +++ b/internal/k8s/testing.go @@ -177,6 +177,26 @@ func SetTestContextName(name string) string { return prev } +// SetTestRegistryEntry is a test-only helper that registers one context in the +// isolated-load registry, so callers can exercise resolution against a +// multi-kubeconfig layout. Returns a restore func. +func SetTestRegistryEntry(qualifiedName, sourceFile, inFileName string) func() { + clientMu.Lock() + prev := contextRegistry + next := make(map[string]contextEntry, len(prev)+1) + for k, v := range prev { + next[k] = v + } + next[qualifiedName] = contextEntry{SourceFile: sourceFile, InFileName: inFileName} + contextRegistry = next + clientMu.Unlock() + return func() { + clientMu.Lock() + contextRegistry = prev + clientMu.Unlock() + } +} + // SetTestContextNamespace is a test-only helper that overrides the package-level // kubeconfig context namespace. Returns the previous value so callers can // restore it on cleanup. @@ -247,6 +267,13 @@ func ResetTestState() { connectionCallbacks = nil connectionCallbacksMu.Unlock() + contextSwitchMu.Lock() + beforeContextSwitchCallbacks = nil + contextSwitchCallbacks = nil + namespaceRescopeCallbacks = nil + contextSwitchProgressCallbacks = nil + contextSwitchMu.Unlock() + runtimeAuthChecksMu.Lock() runtimeAuthChecks = make(map[uint64]struct{}) runtimeAuthCooldownGeneration = 0 diff --git a/internal/server/last_context_privacy_test.go b/internal/server/last_context_privacy_test.go new file mode 100644 index 000000000..16549f99b --- /dev/null +++ b/internal/server/last_context_privacy_test.go @@ -0,0 +1,70 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/skyhook-io/radar/internal/settings" + "github.com/skyhook-io/radar/pkg/auth" +) + +// The remembered cluster shares settings.json with the user's preferences, so +// /api/settings must strip it both ways — otherwise every viewer of a shared +// instance learns which cluster this $HOME last ran Desktop against. The PUT +// half also pins that handlePutSettings stays a patch: a body that never +// mentions lastDesktopContext must not erase it. +func TestSettingsEndpointNeverCarriesTheRememberedCluster(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) + + if err := settings.Save(settings.Settings{Theme: "dark"}); err != nil { + t.Fatalf("seed settings: %v", err) + } + if _, err := settings.Update(func(st *settings.Settings) { + st.LastDesktopContext = &settings.LastContext{Name: "prod-eu"} + }); err != nil { + t.Fatalf("seed desktop state: %v", err) + } + + for _, tc := range []struct { + name string + server *Server + }{ + {"local", &Server{}}, + {"auth-enabled", &Server{authConfig: auth.Config{Mode: "oidc"}}}, + } { + t.Run(tc.name, func(t *testing.T) { + get := httptest.NewRecorder() + tc.server.handleGetSettings(get, httptest.NewRequest(http.MethodGet, "/api/settings", nil)) + assertNoRememberedCluster(t, "GET", get.Body.String()) + + put := httptest.NewRecorder() + tc.server.handlePutSettings(put, httptest.NewRequest( + http.MethodPut, "/api/settings", strings.NewReader(`{"theme":"light"}`))) + assertNoRememberedCluster(t, "PUT", put.Body.String()) + + // ...and the PUT must not have erased it on disk either. + if settings.Load().LastDesktopContext == nil { + t.Error("a PUT that never mentioned it dropped the remembered cluster") + } + }) + } +} + +func assertNoRememberedCluster(t *testing.T, verb, body string) { + t.Helper() + var payload map[string]any + if err := json.Unmarshal([]byte(body), &payload); err != nil { + t.Fatalf("%s decode: %v", verb, err) + } + if v, has := payload["lastDesktopContext"]; has { + t.Errorf("%s /api/settings carried the remembered cluster: %v", verb, v) + } + if strings.Contains(body, "prod-eu") { + t.Errorf("%s /api/settings body mentions the remembered cluster: %s", verb, body) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index c3075552c..4633f9066 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -5046,6 +5046,9 @@ func deploymentMode() k8s.DeploymentMode { func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) { loaded := settings.Load() + // Desktop's own state: on a shared instance this would hand every viewer + // the cluster name from whenever this $HOME last ran the Desktop app. + loaded.LastDesktopContext = nil if cloudMode() { // Strip user-scoped fields — Cloud's intercept layer fills them from // user_preferences. Audit stays because it's cluster-shared policy. @@ -5083,6 +5086,8 @@ func (s *Server) handlePutSettings(w http.ResponseWriter, r *http.Request) { s.writeError(w, http.StatusInternalServerError, err.Error()) return } + // The response echoes the merged struct — same reason as handleGetSettings. + result.LastDesktopContext = nil if cloudMode() { result.Theme = "" result.PinnedKinds = nil diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 81b5c015a..d69a17ccd 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -52,6 +52,23 @@ type Settings struct { // cluster-scoped: a registry is where your charts live, independent of which // cluster they're deployed to. HelmOCISources []string `json:"helmOciSources,omitempty"` + // LastDesktopContext is the context the Desktop window last used, reopened + // on the next launch. Desktop-scoped by name because + // `kubectl radar` shares this file and must never follow it: a command + // typed after `kubectl config use-context` runs where the shell says it + // will. + LastDesktopContext *LastContext `json:"lastDesktopContext,omitempty"` +} + +// LastContext identifies a context precisely enough to survive a restart. +// Name alone is not enough: with several kubeconfigs, which file owns the +// unqualified name depends on directory read order, so a new file can steal it +// and point the restore at another cluster. SourceFile + InFileName are the +// identity; Name is the label. +type LastContext struct { + Name string `json:"name"` + SourceFile string `json:"sourceFile,omitempty"` + InFileName string `json:"inFileName,omitempty"` } // mu serializes Load-mutate-Save cycles to prevent concurrent PUTs from @@ -134,6 +151,20 @@ func Update(mutate func(*Settings)) (Settings, error) { return s, Save(s) } +// UpdateChecked refuses to write when existing settings cannot be read. It is +// for automatic writers, which must not replace a damaged or temporarily +// unavailable file with a mutated zero value. +func UpdateChecked(mutate func(*Settings)) (Settings, error) { + mu.Lock() + defer mu.Unlock() + s, err := LoadChecked() + if err != nil { + return Settings{}, err + } + mutate(&s) + return s, Save(s) +} + // RolloutKey returns the local value staged rollouts hash on, minting and // persisting one on first use. It never leaves this machine. Returns "" when it // cannot be persisted (no home directory, read-only filesystem) — a caller diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go index 4671001f2..00cf34376 100644 --- a/internal/settings/settings_test.go +++ b/internal/settings/settings_test.go @@ -73,6 +73,33 @@ func TestUpdateMergesFields(t *testing.T) { } } +func TestUpdateCheckedDoesNotOverwriteInvalidSettings(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) + path := filepath.Join(dir, ".radar", "settings.json") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + const invalid = "{not-json" + if err := os.WriteFile(path, []byte(invalid), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := UpdateChecked(func(s *Settings) { + s.Theme = "dark" + }); err == nil { + t.Fatal("UpdateChecked succeeded with invalid existing settings") + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != invalid { + t.Errorf("settings were overwritten: got %q, want %q", data, invalid) + } +} + func TestEmptySettingsProducesMinimalJSON(t *testing.T) { s := Settings{} data, err := json.Marshal(s) diff --git a/web/src/components/settings/SettingsDialog.tsx b/web/src/components/settings/SettingsDialog.tsx index 865b17548..48ce6fff9 100644 --- a/web/src/components/settings/SettingsDialog.tsx +++ b/web/src/components/settings/SettingsDialog.tsx @@ -42,6 +42,7 @@ interface Config { argoCdUrl?: string argoCdInsecureTls?: boolean mcp?: boolean | null + restoreLastDesktopContext?: boolean | null } interface ConfigResponse { @@ -95,6 +96,7 @@ function normalizeStartup(c: Config) { timelineDbPath: c.timelineDbPath ?? '', historyLimit: c.historyLimit ?? null, mcp: c.mcp ?? true, + restoreLastDesktopContext: c.restoreLastDesktopContext ?? true, } } @@ -148,7 +150,8 @@ export function SettingsDialog({ const clusterDirty = edN.kubeconfig !== svN.kubeconfig || edN.kubeconfigDirs !== svN.kubeconfigDirs || - edN.namespace !== svN.namespace + edN.namespace !== svN.namespace || + edN.restoreLastDesktopContext !== svN.restoreLastDesktopContext const serverDirty = edN.port !== svN.port || edN.noBrowser !== svN.noBrowser || edN.browser !== svN.browser const mcpDirty = edN.mcp !== svN.mcp @@ -462,6 +465,7 @@ export function SettingsDialog({ @@ -986,10 +990,12 @@ function AIUnavailableNotice() { function ClusterSection({ config, effectiveConfig, + isDesktop, onChange, }: { config: Config effectiveConfig?: Config + isDesktop: boolean onChange: (field: K, value: Config[K]) => void }) { return ( @@ -1018,6 +1024,14 @@ function ClusterSection({ placeholder="All namespaces" onChange={(v) => onChange('namespace', v || undefined)} /> + {isDesktop && ( + onChange('restoreLastDesktopContext', v ? undefined : false)} + /> + )} ) }