Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/desktop/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ func main() {
log.Printf("K8s init failed (will show in UI): %v", k8sInitErr)
k8s.SetConnectionStatus(k8s.ConnectionStatus{
State: k8s.StateDisconnected,
Context: k8s.GetContextName(),
Error: k8sInitErr.Error(),
ErrorType: "config",
})
Expand Down
25 changes: 22 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -597,16 +597,35 @@ Switching clusters in Radar leaves existing local shells running. A terminal
opened for another context shows **Different context** and a **New terminal**
action in the existing toolbar. Hover over the notice to see both full context
names. The action opens a shell for the context Radar is now showing.
Reconnecting starts a new shell using Radar's then-active context, and updates
the tab's label from the server's new session.
Each tab retains the full context selected when it was opened. Reconnecting
starts a new shell only while Radar has that context selected; otherwise it
asks you to switch back or open a new terminal for the current context. The
tab keeps its previous context label during reconnect attempts.
Reconnect and Retry are disabled while Radar is showing a different context.
When a context is selected, the toolbar says **Requested** before the first shell starts. The server checks
the requested context against the same snapshot it exports, so switching
contexts while a tab is opening cannot start that shell for a different context.
Commands supplied by actions such as **Authenticate in terminal** are sent once
per tab. Reconnecting does not repeat a command that was already sent. If the
connection closes before it is sent, the command remains pending for the next
connection.

If Radar cannot create a temporary kubeconfig, the existing original/inherited
kubeconfig fallback remains available and the tab says **Context not confirmed**.
That shell's Kubernetes target has not been established by Radar.
That shell's Kubernetes target has not been established by Radar. If the selected
context differs from the tab's requested context, its notice says **Requested
context differs**, and the tooltip keeps that request distinct from a confirmed
kubeconfig.
The Desktop app can also open an unconfirmed recovery shell when no kubeconfig
is available. Its explicit empty-context intent must match the server's empty
active context; once a context is selected, open a new terminal for it.
These checks preserve context selection across opens and reconnects; they do
not enforce a shell's live command target or detect a kubeconfig context being
repointed to a different physical cluster under the same name.
If a context switch fails before Radar changes its active client, a terminal
requested for the new context is refused. Finish recovering that connection,
switch back to the previous context, or copy the recovery command into an
external terminal. Radar will not substitute a shell for the previous context.

## Namespace Picker

Expand Down
13 changes: 10 additions & 3 deletions internal/k8s/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -964,7 +964,7 @@ func recordEmptyCommandWarning(source string, authInfos []string) {
// current-context set to Radar's active context. The caller must remove the
// file when done. Returns the temp file path.
func WriteKubeconfigForCurrentContext() (string, error) {
snapshot, err := WriteKubeconfigSnapshotForCurrentContext()
snapshot, err := WriteKubeconfigSnapshotForCurrentContext(nil)
return snapshot.Path, err
}

Expand All @@ -973,9 +973,12 @@ type KubeconfigSnapshot struct {
Context string
}

var ErrKubeconfigContextMismatch = errors.New("terminal context changed")

// WriteKubeconfigSnapshotForCurrentContext returns the temporary config and its
// display context from the same client-state snapshot. The caller owns the file.
func WriteKubeconfigSnapshotForCurrentContext() (KubeconfigSnapshot, error) {
// display context from the same client-state snapshot. A non-nil expectedContext
// must match exactly, including an empty context. The caller owns the file.
func WriteKubeconfigSnapshotForCurrentContext(expectedContext *string) (KubeconfigSnapshot, error) {
clientMu.RLock()
ctx := contextName
activeFile := activeSourceFile
Expand All @@ -986,6 +989,10 @@ func WriteKubeconfigSnapshotForCurrentContext() (KubeconfigSnapshot, error) {
singlePath := kubeconfigPath
clientMu.RUnlock()

if expectedContext != nil && *expectedContext != ctx {
return KubeconfigSnapshot{}, fmt.Errorf("%w: this terminal is for %q; Radar is showing %q", ErrKubeconfigContextMismatch, *expectedContext, ctx)
}

var rawConfig clientcmdapi.Config
var currentContextForFile string

Expand Down
8 changes: 7 additions & 1 deletion internal/k8s/context_registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -652,11 +652,17 @@ func TestWriteKubeconfigSnapshotPreservesDisplayContext(t *testing.T) {
contextName, activeSourceFile, activeSourceName, activeSourceConfig = oldName, oldFile, oldSourceName, oldConfig
clientMu.Unlock()
})
snapshot, err := WriteKubeconfigSnapshotForCurrentContext()
expected := "production@secondary"
snapshot, err := WriteKubeconfigSnapshotForCurrentContext(&expected)
if err != nil {
t.Fatal(err)
}
defer os.Remove(snapshot.Path)
expected = "production"
rejected, err := WriteKubeconfigSnapshotForCurrentContext(&expected)
if !errors.Is(err, ErrKubeconfigContextMismatch) || rejected.Path != "" {
t.Fatalf("unqualified context accepted: snapshot=%+v error=%v", rejected, err)
}
SetTestContextName("staging")
written, err := clientcmd.LoadFromFile(snapshot.Path)
if err != nil {
Expand Down
29 changes: 24 additions & 5 deletions internal/server/localterm.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package server

import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
Expand Down Expand Up @@ -111,13 +112,33 @@ func (s *Server) localTerminalUnavailable(r *http.Request) (int, string) {
return 0, ""
}

// handleLocalTerminal handles WebSocket connections for local terminal sessions
// handleLocalTerminal opens a shell only when expectedContext matches the
// exported client-state snapshot. It never switches Radar's active context.
func (s *Server) handleLocalTerminal(w http.ResponseWriter, r *http.Request) {
if status, message := s.localTerminalUnavailable(r); status != 0 {
s.writeError(w, status, message)
return
}

if !s.websocketOriginAllowed(r) {
s.writeError(w, http.StatusForbidden, "local terminal origin is not allowed")
return
}

expectedContext := r.URL.Query().Get("expectedContext")
if !r.URL.Query().Has("expectedContext") {
s.writeError(w, http.StatusBadRequest, "Open a new terminal from Radar to select its context")
return
}
kubeconfig, configErr := k8s.WriteKubeconfigSnapshotForCurrentContext(&expectedContext)
if errors.Is(configErr, k8s.ErrKubeconfigContextMismatch) {
s.writeError(w, http.StatusConflict, configErr.Error())
return
}
if kubeconfig.Path != "" {
defer os.Remove(kubeconfig.Path)
}

// Upgrade to WebSocket
conn, err := s.upgradeWebSocket(w, r)
if err != nil {
Expand All @@ -136,19 +157,17 @@ func (s *Server) handleLocalTerminal(w http.ResponseWriter, r *http.Request) {
// Set up environment: inherit current process env, override KUBECONFIG
// with a temp copy that has current-context set to Radar's active context.
env := os.Environ()
kubeconfig, err := k8s.WriteKubeconfigSnapshotForCurrentContext()
tmpKubeconfig := kubeconfig.Path
sessionInfo := localTermSessionInfo{Type: "session"}
if err != nil {
log.Printf("[localterm] Failed to write temp kubeconfig, falling back to default: %v", err)
if configErr != nil {
log.Printf("[localterm] Failed to write temp kubeconfig, falling back to default: %v", configErr)
if kubeconfigPath := k8s.GetKubeconfigPath(); kubeconfigPath != "" {
env = setEnv(env, "KUBECONFIG", kubeconfigPath)
}
} else {
env = setEnv(env, "KUBECONFIG", tmpKubeconfig)
sessionInfo.Context = kubeconfig.Context
sessionInfo.KubeconfigIsolated = true
defer os.Remove(tmpKubeconfig)
}

// Ensure TERM is set so the shell's terminfo binds the escape sequences
Expand Down
125 changes: 121 additions & 4 deletions internal/server/localterm_context_unix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package server
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
Expand All @@ -17,8 +18,16 @@ import (
)

func TestLocalTerminalReportsExportedContextBeforeOutput(t *testing.T) {
for _, isolated := range []bool{true, false} {
t.Run(map[bool]string{true: "isolated", false: "fallback"}[isolated], func(t *testing.T) {
for _, tc := range []struct {
name, context string
isolated bool
}{
{"isolated", "production", true},
{"fallback", "missing", false},
{"no active context", "", false},
} {
t.Run(tc.name, func(t *testing.T) {
isolated := tc.isolated
t.Cleanup(k8s.SetTestLocalMode())
previousDisabled := k8s.ForceDisableLocalTerminal
k8s.ForceDisableLocalTerminal = false
Expand All @@ -32,13 +41,14 @@ func TestLocalTerminalReportsExportedContextBeforeOutput(t *testing.T) {
if isolated {
t.Cleanup(k8s.SetTestProfileSource(filepath.Join(dir, "absent.yaml"), "production", "demo-user"))
} else {
t.Cleanup(k8s.SetTestProfileSource("", "missing", ""))
t.Cleanup(k8s.SetTestProfileSource("", tc.context, ""))
t.Cleanup(k8s.SetTestRegistryEntry("other", filepath.Join(dir, "absent.yaml"), "other"))
}
server := &Server{listenAddress: DefaultListenAddress}
httpServer := httptest.NewServer(http.HandlerFunc(server.handleLocalTerminal))
defer httpServer.Close()
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(httpServer.URL, "http")+"/api/local-terminal", nil)
expected := tc.context
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(httpServer.URL, "http")+"/api/local-terminal?expectedContext="+url.QueryEscape(expected), nil)
if err != nil {
t.Fatal(err)
}
Expand All @@ -65,3 +75,110 @@ func TestLocalTerminalReportsExportedContextBeforeOutput(t *testing.T) {
})
}
}

func TestLocalTerminalContextGuardBeforeShell(t *testing.T) {
t.Cleanup(k8s.SetTestLocalMode())
previousDisabled := k8s.ForceDisableLocalTerminal
k8s.ForceDisableLocalTerminal = false
t.Cleanup(func() { k8s.ForceDisableLocalTerminal = previousDisabled })
dir := t.TempDir()
t.Cleanup(k8s.SetTestProfileSource(filepath.Join(dir, "absent.yaml"), "production@secondary", "demo-user"))
t.Setenv("TMPDIR", dir)
marker := filepath.Join(dir, "shell-started")
shell := filepath.Join(dir, "fixture-shell")
if err := os.WriteFile(shell, []byte("#!/bin/sh\ntouch \"$GUARD_SHELL_MARKER\"\nprintf 'fixture output\\n'\ncat\n"), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("SHELL", shell)
t.Setenv("GUARD_SHELL_MARKER", marker)
server := &Server{listenAddress: DefaultListenAddress}
httpServer := httptest.NewServer(http.HandlerFunc(server.handleLocalTerminal))
defer httpServer.Close()

for _, tc := range []struct {
name, expected, origin string
status int
}{
{"missing", "", "", http.StatusBadRequest},
{"empty intent with active context", "", "", http.StatusConflict},
{"different context", "staging", "", http.StatusConflict},
{"same short name", "production@primary", "", http.StatusConflict},
{"cross origin", "production@secondary", "http://attacker.example", http.StatusForbidden},
} {
t.Run(tc.name, func(t *testing.T) {
headers := http.Header{}
if tc.origin != "" {
headers.Set("Origin", tc.origin)
}
requestURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/api/local-terminal"
if tc.name != "missing" {
requestURL += "?expectedContext=" + url.QueryEscape(tc.expected)
}
conn, resp, err := websocket.DefaultDialer.Dial(requestURL, headers)
if conn != nil {
conn.Close()
t.Fatal("rejected context opened a WebSocket")
}
if err == nil || resp == nil || resp.StatusCode != tc.status {
t.Fatalf("handshake: response=%v error=%v, want %d", resp, err, tc.status)
}
resp.Body.Close()
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("rejected request started the shell: %v", err)
}
})
}

t.Run("matching plain request cleans exported kubeconfig", func(t *testing.T) {
resp, err := http.Get(httpServer.URL + "/api/local-terminal?expectedContext=production%40secondary")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("plain GET status = %d", resp.StatusCode)
}
files, err := filepath.Glob(filepath.Join(dir, "radar-kubeconfig-*.yaml"))
if err != nil || len(files) != 0 {
t.Fatalf("kubeconfigs after failed upgrade = %v, error = %v", files, err)
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("failed upgrade started a shell: %v", err)
}
})

oldStatus := k8s.GetConnectionStatus()
k8s.SetConnectionStatus(k8s.ConnectionStatus{State: k8s.StateDisconnected, Context: "staging", ErrorType: "auth"})
t.Cleanup(func() { k8s.SetConnectionStatus(oldStatus) })
conn, resp, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(httpServer.URL, "http")+"/api/local-terminal?expectedContext=staging", nil)
if conn != nil {
conn.Close()
t.Fatal("failed switch silently opened the previous active context")
}
if err == nil || resp == nil || resp.StatusCode != http.StatusConflict {
t.Fatalf("failed switch: response=%v error=%v", resp, err)
}
resp.Body.Close()

k8s.SetConnectionStatus(k8s.ConnectionStatus{State: k8s.StateDisconnected, Context: k8s.GetContextName(), ErrorType: "config"})
conn, _, err = websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(httpServer.URL, "http")+"/api/local-terminal?expectedContext=production%40secondary", nil)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
var info localTermSessionInfo
if err := conn.ReadJSON(&info); err != nil {
t.Fatal(err)
}
if info.Context != "production@secondary" || !info.KubeconfigIsolated {
t.Fatalf("matching disconnected context did not open with cached config: %+v", info)
}
var output TerminalMessage
if err := conn.ReadJSON(&output); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(marker); err != nil {
t.Fatalf("accepted request did not start the shell: %v", err)
}
}
8 changes: 8 additions & 0 deletions packages/k8s-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ This describes the supplied kubeconfig, not a shell's live command target.
`toolbarExtra` lets the host render that information in the terminal toolbar.
Hosts can update a dock tab's label and optional full-name tooltip with
`useDock().setTabTitle(id, title, titleTooltip)`.
The optional `DockTab.localTerminalContext` field lets a host retain the full
context selected when a local terminal was requested. An empty string records
no active context; an omitted field records no intent. The shared terminal and
open hook do not interpret it; Radar's host wrapper checks it on open/reconnect.
`canConnect` is a predicate evaluated during rendering to disable Reconnect/Retry,
and before an attempt to preserve the existing terminal when refused.
`onConnectionError` lets a host refresh connection state after a failed WebSocket
handshake.

`initialCommand` is sent once per mounted terminal. Reconnect does not repeat a
command that was already sent; if the connection closes before delivery, the
Expand Down
1 change: 1 addition & 0 deletions packages/k8s-ui/src/components/dock/DockContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface DockTab {
nodeName?: string
// Local terminal props
initialCommand?: string
localTerminalContext?: string
}

export interface DockContextValue {
Expand Down
42 changes: 42 additions & 0 deletions packages/k8s-ui/src/components/dock/LocalTerminalTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { act, StrictMode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { LocalTerminalTab, type LocalTerminalTabProps } from './LocalTerminalTab'
import { TerminalTab } from './TerminalTab'

vi.mock('@xterm/xterm', () => ({
Terminal: class {
Expand Down Expand Up @@ -145,3 +146,44 @@ it('sends the initial command independently in each terminal tab', async () => {
expect(socket.send.mock.calls.filter(([data]) => JSON.parse(data).type === 'input')).toEqual([[input]])
}
})

it('keeps a blocked reconnect intact until the host permits it', async () => {
vi.useFakeTimers()
let allowed = true
const createSession = vi.fn(async () => ({ wsUrl: 'ws://localhost' }))
const onSessionInfo = vi.fn()
await render({ createSession, canConnect: () => allowed, initialCommand: 'auth-command', onSessionInfo })
await act(async () => Socket.instances[0].onopen?.())
await act(async () => vi.advanceTimersByTime(300))
await act(async () => Socket.instances[0].close())
const terminal = element.querySelector('div.absolute')
expect(terminal).not.toBeNull()
const metadataCalls = onSessionInfo.mock.calls.length
allowed = false
await act(async () => element.querySelector<HTMLButtonElement>('button')!.click())
expect(createSession).toHaveBeenCalledTimes(1)
expect(onSessionInfo).toHaveBeenCalledTimes(metadataCalls)
expect(element.contains(terminal)).toBe(true)
allowed = true
await act(async () => element.querySelector<HTMLButtonElement>('button')!.click())
await act(async () => Socket.instances[1].onopen?.())
await act(async () => vi.advanceTimersByTime(300))
expect(createSession).toHaveBeenCalledTimes(2)
expect(Socket.instances.flatMap(s => s.send.mock.calls).filter(([data]) => JSON.parse(data).type === 'input')).toHaveLength(1)
})

it.each(['local', 'pod'])('allows Retry after a %s terminal creation error', async kind => {
const createSession = vi.fn()
.mockRejectedValueOnce(new Error('creation failed'))
.mockResolvedValueOnce({ wsUrl: 'ws://localhost' })
await act(async () => root.render(kind === 'local'
? <LocalTerminalTab createSession={createSession} />
: <TerminalTab namespace="default" podName="pod" containerName="app" containers={['app']} createSession={createSession} />))
expect(element.textContent).toContain('creation failed')
const retry = [...element.querySelectorAll('button')].find(button => button.textContent?.trim() === 'Retry')!
await act(async () => retry.click())
expect(createSession).toHaveBeenCalledTimes(2)
expect(Socket.instances).toHaveLength(1)
await act(async () => Socket.instances[0].onopen?.())
expect(element.textContent).not.toContain('creation failed')
})
Loading
Loading