feat: open terminal in the running container

Signed-off-by: Vladyslav Zhukovskyi <vzhukovs@redhat.com>
This commit is contained in:
Vladyslav Zhukovskyi 2024-03-20 15:22:00 +02:00
parent 23ebd88a3e
commit 66734cee5c
14 changed files with 1053 additions and 0 deletions

View file

@ -1921,6 +1921,53 @@ export class PluginSystem {
},
);
const kubernetesExecCallbackMap = new Map<
number,
{ onStdIn: (data: string) => void; onResize: (columns: number, rows: number) => void }
>();
this.ipcHandle(
'kubernetes-client:execIntoContainer',
async (_listener, podName: string, containerName: string, onDataId: number): Promise<number> => {
const execInvocation = await kubernetesClient.execIntoContainer(
podName,
containerName,
(stdOut: Buffer) => {
this.getWebContentsSender().send('kubernetes-client:execIntoContainer-onData', onDataId, stdOut);
},
(stdErr: Buffer) => {
this.getWebContentsSender().send('kubernetes-client:execIntoContainer-onError', onDataId, stdErr);
},
() => {
this.getWebContentsSender().send('kubernetes-client:execIntoContainer-onClose', onDataId);
kubernetesExecCallbackMap.delete(onDataId);
},
);
kubernetesExecCallbackMap.set(onDataId, execInvocation);
return onDataId;
},
);
this.ipcHandle(
'kubernetes-client:execIntoContainerSend',
async (_listener, onDataId: number, content: string): Promise<void> => {
const callback = kubernetesExecCallbackMap.get(onDataId);
if (callback) {
callback.onStdIn(content);
}
},
);
this.ipcHandle(
'kubernetes-client:execIntoContainerResize',
async (_listener, onDataId: number, width: number, height: number): Promise<void> => {
const callback = kubernetesExecCallbackMap.get(onDataId);
if (callback) {
callback.onResize(width, height);
}
},
);
this.ipcHandle('feedback:send', async (_listener, feedbackProperties: unknown): Promise<void> => {
return telemetry.sendFeedback(feedbackProperties);
});

View file

@ -19,19 +19,23 @@
import * as fs from 'node:fs';
import { IncomingMessage } from 'node:http';
import { Socket } from 'node:net';
import type { Readable, Writable } from 'node:stream';
import {
Exec,
KubeConfig,
type KubernetesObject,
type V1Deployment,
type V1Ingress,
type V1Service,
type V1Status,
type Watch,
} from '@kubernetes/client-node';
import * as clientNode from '@kubernetes/client-node';
import type { FileSystemWatcher } from '@podman-desktop/api';
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
import { ResizableTerminalWriter } from '/@/plugin/kubernetes-exec-transmitter.js';
import type { Telemetry } from '/@/plugin/telemetry/telemetry.js';
import type { ApiSenderType } from './api.js';
@ -101,6 +105,7 @@ const apiSender: ApiSenderType = {
receive: vi.fn(),
};
const execMock = vi.fn();
beforeAll(() => {
vi.mock('@kubernetes/client-node', async () => {
return {
@ -119,6 +124,7 @@ beforeAll(() => {
this.statusCode = statusCode;
}
},
Exec: vi.fn(),
};
});
});
@ -129,6 +135,7 @@ beforeEach(() => {
KubeConfig.prototype.makeApiClient = makeApiClientMock;
KubeConfig.prototype.getContextObject = getContextObjectMock;
KubeConfig.prototype.currentContext = 'context';
Exec.prototype.exec = execMock;
});
test('Create Kubernetes resources with empty should return ok', async () => {
@ -1190,3 +1197,107 @@ test('setupWatcher sends kubernetes-context-update when kubeconfig file is delet
await new Promise(resolve => setTimeout(resolve, 0));
expect(apiSenderSendMock).toHaveBeenCalledWith('kubernetes-context-update');
});
test('Test should exec into container ', async () => {
const client = createTestClient('default');
makeApiClientMock.mockReturnValue({
getCode: () => Promise.resolve({ body: { gitVersion: 'v1.20.0' } }),
});
let stdout = '';
const onStdOutFn = (data: Buffer): void => {
stdout += data.toString();
};
let stderr = '';
const onStdErrFn = (data: Buffer): void => {
stderr += data.toString();
};
const onCloseFn = vi.fn();
execMock.mockImplementation(
(
namespace: string,
podName: string,
containerName: string,
command: string | string[],
stdout: Writable | null,
stderr: Writable | null,
stdin: Readable | null,
tty: boolean,
_?: (status: V1Status) => void,
) => {
expect(namespace).toBe('default');
expect(podName).toBe('test-pod');
expect(containerName).toBe('test-container');
expect(tty).toBeTruthy();
expect(command).toEqual(['/bin/sh', '-c', 'if command -v bash >/dev/null 2>&1; then bash; else sh; fi']);
if (stdout) {
stdout.write('stdOut output');
stdout.on('resize', () => {
expect(stdout).instanceOf(ResizableTerminalWriter);
const { width, height } = (stdout as ResizableTerminalWriter).getDimension();
expect(width).toBe(1);
expect(height).toBe(1);
});
}
if (stderr) {
stderr.write('stdErr output');
}
if (stdin) {
stdin.on('data', chunk => expect(chunk.toString()).toEqual('stdIn input'));
}
return { on: vi.fn() };
},
);
const execResp = await client.execIntoContainer('test-pod', 'test-container', onStdOutFn, onStdErrFn, onCloseFn);
expect(stdout).toBe('stdOut output');
expect(stderr).toBe('stdErr output');
execResp.onStdIn('stdIn input');
execResp.onResize(1, 1);
});
test('Test should throw an exception during exec command if resize parameters are wrong', async () => {
const client = createTestClient('default');
makeApiClientMock.mockReturnValue({
getCode: () => Promise.resolve({ body: { gitVersion: 'v1.20.0' } }),
});
const execResp = await client.execIntoContainer(
'test-pod',
'test-container',
() => {},
() => {},
() => {},
);
expect(() => execResp.onResize(-1, -1)).toThrow('resizing must be done using positive cols and rows');
expect(() => execResp.onResize(0, 0)).toThrow('resizing must be done using positive cols and rows');
expect(() => execResp.onResize(Number.NaN, Number.NaN)).toThrow('resizing must be done using positive cols and rows');
expect(() => execResp.onResize(Infinity, Infinity)).toThrow('resizing must be done using positive cols and rows');
});
test('Test should throw an exception during exec command if internal kube method fails', async () => {
const client = createTestClient('default');
makeApiClientMock.mockReturnValue({
getCode: () => Promise.reject(new Error('K8sError')),
});
await expect(
client.execIntoContainer(
'test-pod',
'test-container',
() => {},
() => {},
() => {},
),
).rejects.toThrowError('not active connection');
});

View file

@ -36,12 +36,14 @@ import type {
V1Pod,
V1PodList,
V1Service,
V1Status,
} from '@kubernetes/client-node';
import {
ApisApi,
AppsV1Api,
CoreV1Api,
CustomObjectsApi,
Exec,
HttpError,
KubeConfig,
KubernetesObjectApi,
@ -65,6 +67,7 @@ import type { FilesystemMonitoring } from './filesystem-monitoring.js';
import type { KubeContext } from './kubernetes-context.js';
import type { ContextGeneralState, ResourceName } from './kubernetes-context-state.js';
import { ContextsManager } from './kubernetes-context-state.js';
import { BufferedStreamWriter, ResizableTerminalWriter, StringLineReader } from './kubernetes-exec-transmitter.js';
import { Uri } from './types/uri.js';
interface KubernetesObjectWithKind extends KubernetesObject {
@ -1240,4 +1243,70 @@ export class KubernetesClient {
public dispose(): void {
this.contextsState.dispose();
}
async execIntoContainer(
podName: string,
containerName: string,
onStdOut: (data: Buffer) => void,
onStdErr: (data: Buffer) => void,
onClose: () => void,
): Promise<{ onStdIn: (data: string) => void; onResize: (columns: number, rows: number) => void }> {
let telemetryOptions = {};
try {
const ns = this.getCurrentNamespace();
const connected = await this.checkConnection();
if (!ns) {
throw new Error('no active namespace');
}
if (!connected) {
throw new Error('not active connection');
}
const stdout = new ResizableTerminalWriter(new BufferedStreamWriter(onStdOut));
const stderr = new ResizableTerminalWriter(new BufferedStreamWriter(onStdErr));
const stdin = new StringLineReader();
const exec = new Exec(this.kubeConfig);
const conn = await exec.exec(
ns,
podName,
containerName,
['/bin/sh', '-c', 'if command -v bash >/dev/null 2>&1; then bash; else sh; fi'],
stdout,
stderr,
stdin,
true,
(_: V1Status) => {
// need to think, maybe it would be better to pass exit code to the client, but on the other hand
// if connection is idle for 15 minutes, websocket connection closes automatically and this handler
// does not call. also need to separate SIGTERM signal (143) and normally exit signals to be able to
// proper reconnect client terminal. at this moment we ignore status and rely on websocket close event
},
);
//need to handle websocket idling, which causes the connection close which is not passed to the execution status
//approx time for idling before closing socket is 15 minutes. code and reason are always undefined here.
conn.on('close', () => {
onClose();
});
return {
onStdIn: (data: string): void => {
stdin.readLine(data);
},
onResize: (columns: number, rows: number): void => {
if (columns <= 0 || rows <= 0 || isNaN(columns) || isNaN(rows) || columns === Infinity || rows === Infinity) {
throw new Error('resizing must be done using positive cols and rows');
}
(stdout as ResizableTerminalWriter).resize({ width: columns, height: rows });
},
};
} catch (error) {
telemetryOptions = { error: error };
throw this.wrapK8sClientError(error);
} finally {
this.telemetry.track('kubernetesExecIntoContainer', telemetryOptions);
}
}
}

View file

@ -0,0 +1,65 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import { expect, test } from 'vitest';
import type { TerminalSize } from '/@/plugin/kubernetes-exec-transmitter.js';
import {
BufferedStreamWriter,
DEFAULT_COLUMNS,
DEFAULT_ROWS,
ResizableTerminalWriter,
StringLineReader,
} from '/@/plugin/kubernetes-exec-transmitter.js';
test('Test should verify string line reader', () => {
const reader = new StringLineReader();
reader.on('data', chunk => {
expect(chunk.toString()).toEqual('foo');
});
reader.push('foo');
});
test('Test should verify buffered stream writer', () => {
const writer = new BufferedStreamWriter((data: Buffer) => {
expect(data.toString()).toEqual('foo');
});
writer.write(Buffer.from('foo'));
});
test('Test should verify resizable terminal writer', () => {
const writer = new ResizableTerminalWriter(
new BufferedStreamWriter((data: Buffer) => {
expect(data.toString()).toEqual('foo');
}),
);
writer.on('resize', () => {
const dimension = writer.getDimension();
expect(dimension).toEqual({ width: 1, height: 1 } as TerminalSize);
});
writer.write(Buffer.from('foo'));
expect(writer.getDimension()).toEqual({ width: DEFAULT_COLUMNS, height: DEFAULT_ROWS } as TerminalSize);
writer.resize({ width: 1, height: 1 } as TerminalSize);
expect(writer.getDimension()).toEqual({ width: 1, height: 1 } as TerminalSize);
});

View file

@ -0,0 +1,116 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import type { WritableOptions } from 'node:stream';
import { Readable, Writable } from 'node:stream';
export const DEFAULT_COLUMNS: number = 80;
export const DEFAULT_ROWS: number = 60;
export interface TerminalSize {
height: number;
width: number;
}
export class ExecStreamWriter extends Writable {
protected transmitter: Writable;
constructor(transmitter: Writable, options?: WritableOptions) {
super(options);
this.transmitter = transmitter;
}
_write(chunk: unknown, encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
this.transmitter._write(chunk, encoding, callback);
}
}
export class ResizableTerminalWriter extends ExecStreamWriter {
protected columns: number;
protected rows: number;
constructor(
transmitter: Writable,
terminalSize: TerminalSize = { width: DEFAULT_COLUMNS, height: DEFAULT_ROWS },
options?: WritableOptions,
) {
super(transmitter, options);
this.columns = terminalSize.width;
this.rows = terminalSize.height;
}
_write(chunk: unknown, encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
super._write(chunk, encoding, callback);
}
resize(terminalSize: TerminalSize): void {
this.columns = terminalSize.width;
this.rows = terminalSize.height;
this.emit('resize');
}
getDimension(): TerminalSize {
return {
width: this.columns,
height: this.rows,
} as TerminalSize;
}
}
export class BufferedStreamWriter extends Writable {
private readonly transmitFn: (data: Buffer) => void;
constructor(transmitFn: (data: Buffer) => void, options?: WritableOptions) {
super(options);
this.transmitFn = transmitFn;
}
_write(chunk: Buffer, encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
this.transmitFn(chunk);
callback();
}
}
export class StringLineReader extends Readable {
private dataQueue: string[] = [];
private isReading: boolean = false;
constructor() {
super();
}
readLine(data: string): void {
this.dataQueue.push(data);
if (!this.isReading) {
this.read();
}
}
_read(): void {
this.isReading = true;
const data = this.dataQueue.shift();
if (data) {
this.push(data);
} else {
this.isReading = false;
this.push(undefined);
}
}
}

View file

@ -1745,6 +1745,55 @@ export function initExposure(): void {
},
);
// callbacks for shellInContainer
let kubernetesCallbackId = 0;
const kubernetesCallbackMap = new Map<
number,
{ onStdOut: (data: Buffer) => void; onStdErr: (data: Buffer) => void; onClose: () => void }
>();
contextBridge.exposeInMainWorld(
'kubernetesExec',
async (
podName: string,
containerName: string,
onStdOut: (data: Buffer) => void,
onStdErr: (data: Buffer) => void,
onClose: () => void,
): Promise<number> => {
kubernetesCallbackId++;
kubernetesCallbackMap.set(kubernetesCallbackId, { onStdOut, onStdErr, onClose });
return ipcInvoke('kubernetes-client:execIntoContainer', podName, containerName, kubernetesCallbackId);
},
);
contextBridge.exposeInMainWorld('kubernetesExecSend', async (dataId: number, content: string): Promise<void> => {
return ipcInvoke('kubernetes-client:execIntoContainerSend', dataId, content);
});
contextBridge.exposeInMainWorld('kubernetesExecResize', async (dataId: number, width: number, height: number) => {
return ipcInvoke('kubernetes-client:execIntoContainerResize', dataId, width, height);
});
ipcRenderer.on('kubernetes-client:execIntoContainer-onData', (_, kubernetesCallbackId: number, data: Buffer) => {
const callback = kubernetesCallbackMap.get(kubernetesCallbackId);
if (callback) {
callback.onStdOut(data);
}
});
ipcRenderer.on('kubernetes-client:execIntoContainer-onError', (_, kubernetesCallbackId: number, data: Buffer) => {
const callback = kubernetesCallbackMap.get(kubernetesCallbackId);
if (callback) {
callback.onStdErr(data);
}
});
ipcRenderer.on('kubernetes-client:execIntoContainer-onClose', (_, kubernetesCallbackId: number) => {
const callback = kubernetesCallbackMap.get(kubernetesCallbackId);
if (callback) {
callback.onClose();
onDataCallbacksShellInContainer.delete(kubernetesCallbackId);
}
});
contextBridge.exposeInMainWorld(
'kubernetesApplyResourcesFromFile',
async (context: string, file: string, namespace?: string): Promise<KubernetesObject[]> => {

View file

@ -0,0 +1,79 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import '@testing-library/jest-dom/vitest';
import { render, waitFor } from '@testing-library/svelte';
import { get } from 'svelte/store';
import { beforeAll, expect, test, vi } from 'vitest';
import KubernetesTerminal from '/@/lib/pod/KubernetesTerminal.svelte';
import { terminalStates } from '/@/stores/kubernetes-terminal-state-store';
const getConfigurationValueMock = vi.fn();
const kubernetesExecMock = vi.fn();
const kubernetesExecResizeMock = vi.fn();
beforeAll(() => {
(window as any).getConfigurationValue = getConfigurationValueMock;
(window as any).kubernetesExec = kubernetesExecMock;
(window as any).kubernetesExecResize = kubernetesExecResizeMock;
(window as any).matchMedia = vi.fn().mockReturnValue({
addListener: vi.fn(),
removeListener: vi.fn(),
});
});
test('Test should render the terminal and being able to reconnect', async () => {
let onStdOutCallback: (data: Buffer) => void = () => {};
const sendCallbackId = 1;
kubernetesExecMock.mockImplementation(
(
_podName: string,
_containerName: string,
onStdOut: (data: Buffer) => void,
_onStdErr: (data: Buffer) => void,
_onClose: () => void,
) => {
onStdOutCallback = onStdOut;
return sendCallbackId;
},
);
const renderObject = render(KubernetesTerminal, { podName: 'podName', containerName: 'containerName' });
await waitFor(() => expect(kubernetesExecMock).toHaveBeenCalled());
onStdOutCallback(Buffer.from('hello\nworld'));
await new Promise(resolve => setTimeout(resolve, 1000));
const terminalLinesLiveRegion = renderObject.container.querySelector('div[aria-live="assertive"]');
expect(terminalLinesLiveRegion).toHaveTextContent('hello world');
const terminals = get(terminalStates);
expect(terminals.size).toBe(0);
renderObject.component.$destroy();
const terminalsAfterDestroy = get(terminalStates);
expect(terminalsAfterDestroy.size).toBe(1);
render(KubernetesTerminal, { podName: 'podName', containerName: 'containerName' });
await new Promise(resolve => setTimeout(resolve, 1000));
expect(kubernetesExecMock).toHaveBeenCalledTimes(1);
});

View file

@ -0,0 +1,156 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { router } from 'tinro';
import { type IDisposable, Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { getPanelDetailColor } from '/@/lib/color/color';
import { terminalStates } from '/@/stores/kubernetes-terminal-state-store';
import { TerminalSettings } from '../../../../main/src/plugin/terminal-settings';
export let podName: string;
export let containerName: string;
export let terminalXtermDiv: HTMLElement = document.createElement('div');
let curRouterPath: string;
interface State {
terminal: Terminal;
id: number;
}
let shellTerminal: Terminal;
let screenReaderMode = true;
let id: number | undefined;
let onDataDisposable: IDisposable | undefined;
router.subscribe(route => {
curRouterPath = route.path;
});
onMount(async () => {
const savedState = getSavedTerminalState(podName, containerName);
if (savedState) {
shellTerminal = savedState.terminal;
id = savedState.id;
removeAllChildren(terminalXtermDiv);
shellTerminal.open(terminalXtermDiv);
} else {
await initializeNewTerminal(terminalXtermDiv);
}
});
onDestroy(() => {
saveTerminalState(podName, containerName, { terminal: shellTerminal, id: id } as State);
});
function removeAllChildren(element: HTMLElement): void {
while (element.firstChild) {
element.removeChild(element.firstChild);
}
}
function reconnect() {
window
.kubernetesExec(
podName,
containerName,
(data: Buffer) => {
shellTerminal.write(data);
},
(data: Buffer) => {
shellTerminal.write(data);
},
reconnect,
)
.then(execId => {
id = execId;
shellTerminal.clear();
onDataDisposable?.dispose();
onDataDisposable = shellTerminal.onData(data => {
window.kubernetesExecSend(id!, data);
});
});
}
async function initializeNewTerminal(container: HTMLElement) {
if (!terminalXtermDiv) {
return;
}
const fontSize = await window.getConfigurationValue<number>(
TerminalSettings.SectionName + '.' + TerminalSettings.FontSize,
);
const lineHeight = await window.getConfigurationValue<number>(
TerminalSettings.SectionName + '.' + TerminalSettings.LineHeight,
);
shellTerminal = new Terminal({
fontSize,
lineHeight,
screenReaderMode,
theme: {
background: getPanelDetailColor(),
},
});
id = await window.kubernetesExec(
podName,
containerName,
(data: Buffer) => {
shellTerminal.write(data);
},
(data: Buffer) => {
shellTerminal.write(data);
},
reconnect,
);
onDataDisposable?.dispose();
onDataDisposable = shellTerminal.onData(data => {
window.kubernetesExecSend(id!, data);
});
const fitAddon = new FitAddon();
shellTerminal.loadAddon(fitAddon);
removeAllChildren(container);
shellTerminal.open(container);
fitAddon.fit();
window.addEventListener('resize', () => {
const resizeAsync = async () => {
//resize all opened terminals
if (curRouterPath.endsWith('/k8s-terminal')) {
fitAddon.fit();
if (id) {
await window.kubernetesExecResize(id, shellTerminal.cols, shellTerminal.rows);
}
}
};
resizeAsync().catch(console.error);
});
await window.kubernetesExecResize(id, shellTerminal.cols, shellTerminal.rows);
}
function getSavedTerminalState(podName: string, containerName: string): State | undefined {
let state;
terminalStates.subscribe(states => {
state = states.get(`${podName}-${containerName}`);
})();
return state ? (state as unknown as State) : undefined;
}
function saveTerminalState(podName: string, containerName: string, state: State) {
terminalStates.update(states => {
states.set(`${podName}-${containerName}`, state);
return states;
});
}
</script>
<div class="h-full" bind:this="{terminalXtermDiv}"></div>

View file

@ -0,0 +1,86 @@
<script lang="ts">
import { onMount } from 'svelte';
import EmptyScreen from '/@/lib/ui/EmptyScreen.svelte';
import NoLogIcon from '/@/lib/ui/NoLogIcon.svelte';
import { podsInfos } from '/@/stores/pods';
import { terminalService } from './KubernetesTerminalService';
import type { PodInfoUI } from './PodInfoUI';
let key = 0;
export let pod: PodInfoUI;
$: currentContainerStatus =
$podsInfos
.find(p => p.Name === pod.name)
?.Containers.reduce((acc, c) => {
acc.set(c.Names, c.Status);
return acc;
}, new Map<string, string>()) || new Map<string, string>();
let currentContainerName = '';
onMount(() => {
if (pod.containers.length > 0) {
currentContainerName = pod.containers[0].Names;
terminalService.ensureTerminalExists(pod.name, currentContainerName);
}
key++;
});
function handleSelectionChange(event: Event) {
const target = event.target as HTMLSelectElement;
currentContainerName = target.value;
terminalService.ensureTerminalExists(pod.name, currentContainerName);
key++;
}
</script>
<div class="flex py-2">
<label
for="input-standard-{pod.name}"
class="block w-auto text-sm font-medium whitespace-nowrap leading-6 text-gray-900 pl-2 pr-2">
{#key key}
{#if terminalService.hasTerminal(pod.name, currentContainerName) && currentContainerStatus.get(currentContainerName) === 'running'}
Connected to:
{:else}
Connecting to:
{/if}
{/key}
</label>
<div class="w-full">
{#if pod.containers.length > 1}
<select
on:change="{handleSelectionChange}"
aria-labelledby="listbox-label"
class="block w-48 p-1 outline-none text-sm bg-charcoal-800 rounded-sm text-gray-700 placeholder-gray-700"
name="{pod.name}"
id="input-standard-{pod.name}">
{#each pod.containers as container}
<option value="{container.Names}">{container.Names}</option>
{/each}
</select>
{:else}
<span
id="input-standard-{pod.name}"
class="block text-sm font-bold leading-6 text-gray-900"
aria-labelledby="listbox-label">{currentContainerName}</span>
{/if}
</div>
</div>
{#key key}
{#if terminalService.hasTerminal(pod.name, currentContainerName) && currentContainerStatus.get(currentContainerName) === 'running'}
<svelte:component
this="{terminalService.getTerminal(pod.name, currentContainerName).component}"
{...terminalService.getTerminal(pod.name, currentContainerName).props} />
{/if}
{/key}
<EmptyScreen
hidden="{!currentContainerStatus.get(currentContainerName)}"
icon="{NoLogIcon}"
title="No Terminal"
message="Container is not running" />

View file

@ -0,0 +1,92 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import '@testing-library/jest-dom/vitest';
import type { PodContainerInfo, PodInfo } from '@podman-desktop/api';
import { beforeEach, expect, test } from 'vitest';
import { TerminalService } from '/@/lib/pod/KubernetesTerminalService';
let terminalService: TestableKubernetesTerminalService;
class TestableKubernetesTerminalService extends TerminalService {
public testInvalidateCacheRecordOnStatusUpdate(podsInfos: PodInfo[]) {
return this.invalidateCacheRecordOnStatusUpdate(podsInfos);
}
public testToKey(podName: string, containerName: string): string {
return super.toKey(podName, containerName);
}
public testInvalidateCacheRecordOnPodRemove(podsInfos: PodInfo[]) {
return this.invalidateCacheRecordOnPodRemove(podsInfos);
}
public testTerminalCache() {
return this.terminalCache;
}
}
beforeEach(() => {
terminalService = new TestableKubernetesTerminalService();
});
test('should create and cache a terminal if it does not exist', () => {
const podName = 'pod1';
const containerName = 'container1';
terminalService.ensureTerminalExists(podName, containerName);
const terminal = terminalService.getTerminal(podName, containerName);
expect(terminal).toBeDefined();
expect(terminal.props.podName).toBe(podName);
expect(terminal.props.containerName).toBe(containerName);
});
test('should check if the terminal exists in the cache', () => {
const podName = 'pod1';
const containerName = 'container1';
terminalService.ensureTerminalExists(podName, containerName);
expect(terminalService.hasTerminal(podName, containerName)).toBeTruthy();
});
test('should invalidate cache for non-running containers', () => {
terminalService.testTerminalCache().set('pod1-container1', {});
const podsInfosMock: PodInfo[] = [
{
Name: 'pod1',
Containers: [{ Names: 'container1', Status: 'exited' } as PodContainerInfo],
} as PodInfo,
];
terminalService.testInvalidateCacheRecordOnStatusUpdate(podsInfosMock);
expect(terminalService.hasTerminal('pod1', 'container1')).toBe(false);
});
test('should invalidate cache when pod is removed', () => {
terminalService.testTerminalCache().set('pod1-container1', {});
terminalService.testInvalidateCacheRecordOnPodRemove([]);
expect(terminalService.hasTerminal('pod1', 'container1')).toBe(false);
});
test('should return correct key identifier', () => {
const key = terminalService.testToKey('pod1', 'container1');
expect(key).toBe('pod1-container1');
});

View file

@ -0,0 +1,88 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import type { PodContainerInfo, PodInfo } from '@podman-desktop/api';
import KubernetesTerminal from '/@/lib/pod/KubernetesTerminal.svelte';
import { terminalStates } from '/@/stores/kubernetes-terminal-state-store';
import { podsInfos } from '/@/stores/pods';
export class TerminalService {
protected terminalCache = new Map();
constructor() {
podsInfos.subscribe($podsInfos => {
this.invalidateCacheRecordOnStatusUpdate($podsInfos);
this.invalidateCacheRecordOnPodRemove($podsInfos);
});
}
protected invalidateCacheRecordOnStatusUpdate(podsInfos: PodInfo[]) {
podsInfos.forEach((pod: PodInfo) => {
pod.Containers.forEach((container: PodContainerInfo) => {
if (container.Status !== 'running') {
this.terminalCache.delete(this.toKey(pod.Name, container.Names));
}
});
});
}
protected invalidateCacheRecordOnPodRemove(podsInfos: PodInfo[]) {
const activePods = new Set(
podsInfos.flatMap((pod: PodInfo) =>
pod.Containers.map((container: PodContainerInfo) => this.toKey(pod.Name, container.Names)),
),
);
for (const [key] of this.terminalCache) {
if (!activePods.has(key)) {
this.invalidateTerminalComponentState(key);
this.terminalCache.delete(key);
}
}
}
ensureTerminalExists(podName: string, containerName: string) {
if (!this.terminalCache.has(this.toKey(podName, containerName))) {
this.terminalCache.set(this.toKey(podName, containerName), {
component: KubernetesTerminal,
props: { podName: podName, containerName },
});
}
}
getTerminal(podName: string, containerName: string) {
return this.terminalCache.get(this.toKey(podName, containerName));
}
hasTerminal(podName: string, containerName: string) {
return this.terminalCache.has(this.toKey(podName, containerName));
}
protected invalidateTerminalComponentState(podAndContainerName: string) {
terminalStates.update(states => {
states.delete(podAndContainerName);
return states;
});
}
protected toKey(podName: string, containerName: string) {
return `${podName}-${containerName}`;
}
}
export const terminalService = new TerminalService();

View file

@ -10,6 +10,7 @@ import DetailsPage from '../ui/DetailsPage.svelte';
import ErrorMessage from '../ui/ErrorMessage.svelte';
import StateChange from '../ui/StateChange.svelte';
import Tab from '../ui/Tab.svelte';
import KubernetesTerminalBrowser from './KubernetesTerminalBrowser.svelte';
import { PodUtils } from './pod-utils';
import PodActions from './PodActions.svelte';
import PodDetailsInspect from './PodDetailsInspect.svelte';
@ -79,6 +80,9 @@ onMount(() => {
<Tab title="Logs" url="logs" />
<Tab title="Inspect" url="inspect" />
<Tab title="Kube" url="kube" />
{#if pod.kind === 'kubernetes'}
<Tab title="Terminal" url="k8s-terminal" />
{/if}
</svelte:fragment>
<svelte:fragment slot="content">
<Route path="/summary" breadcrumb="Summary" navigationHint="tab">
@ -93,6 +97,9 @@ onMount(() => {
<Route path="/kube" breadcrumb="Kube" navigationHint="tab">
<PodDetailsKube pod="{pod}" />
</Route>
<Route path="/k8s-terminal" breadcrumb="Terminal" navigationHint="tab">
<KubernetesTerminalBrowser pod="{pod}" />
</Route>
</svelte:fragment>
</DetailsPage>
{/if}

View file

@ -0,0 +1,67 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import { render, waitFor } from '@testing-library/svelte';
import { get } from 'svelte/store';
import { beforeAll, expect, test, vi } from 'vitest';
import KubernetesTerminal from '/@/lib/pod/KubernetesTerminal.svelte';
import { terminalStates } from '/@/stores/kubernetes-terminal-state-store';
const kubernetesExecMock = vi.fn();
beforeAll(() => {
(window as any).getConfigurationValue = vi.fn();
(window as any).kubernetesExec = kubernetesExecMock;
(window as any).kubernetesExecResize = vi.fn();
(window as any).matchMedia = vi.fn().mockReturnValue({
addListener: vi.fn(),
removeListener: vi.fn(),
});
});
test('Test should check saved terminal state after destroying terminal window', async () => {
const sendCallbackId = 1;
kubernetesExecMock.mockImplementation(
(
_podName: string,
_containerName: string,
_: (data: Buffer) => void,
_onStdErr: (data: Buffer) => void,
_onClose: () => void,
) => {
return sendCallbackId;
},
);
const renderObject = render(KubernetesTerminal, { podName: 'podName', containerName: 'containerName' });
await waitFor(() => expect(kubernetesExecMock).toHaveBeenCalled());
const terminals = get(terminalStates);
expect(terminals.size).toBe(0);
renderObject.component.$destroy();
const terminalsAfterDestroy = get(terminalStates);
expect(terminalsAfterDestroy.size).toBe(1);
const state = terminalsAfterDestroy.get('podName-containerName');
expect(state.id).toBe(sendCallbackId);
expect(state.terminal).toBeDefined();
});

View file

@ -0,0 +1,21 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import { writable } from 'svelte/store';
export const terminalStates = writable(new Map());