diff --git a/packages/main/src/plugin/index.ts b/packages/main/src/plugin/index.ts index 63169bf6242..77dc395d03f 100644 --- a/packages/main/src/plugin/index.ts +++ b/packages/main/src/plugin/index.ts @@ -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 => { + 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 => { + const callback = kubernetesExecCallbackMap.get(onDataId); + if (callback) { + callback.onStdIn(content); + } + }, + ); + + this.ipcHandle( + 'kubernetes-client:execIntoContainerResize', + async (_listener, onDataId: number, width: number, height: number): Promise => { + const callback = kubernetesExecCallbackMap.get(onDataId); + if (callback) { + callback.onResize(width, height); + } + }, + ); + this.ipcHandle('feedback:send', async (_listener, feedbackProperties: unknown): Promise => { return telemetry.sendFeedback(feedbackProperties); }); diff --git a/packages/main/src/plugin/kubernetes-client.spec.ts b/packages/main/src/plugin/kubernetes-client.spec.ts index 3db3fbc6bb2..a7775acc146 100644 --- a/packages/main/src/plugin/kubernetes-client.spec.ts +++ b/packages/main/src/plugin/kubernetes-client.spec.ts @@ -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'); +}); diff --git a/packages/main/src/plugin/kubernetes-client.ts b/packages/main/src/plugin/kubernetes-client.ts index 1b0b2ce26aa..c6560a8679b 100644 --- a/packages/main/src/plugin/kubernetes-client.ts +++ b/packages/main/src/plugin/kubernetes-client.ts @@ -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); + } + } } diff --git a/packages/main/src/plugin/kubernetes-exec-transmitter.spec.ts b/packages/main/src/plugin/kubernetes-exec-transmitter.spec.ts new file mode 100644 index 00000000000..212d3fc30da --- /dev/null +++ b/packages/main/src/plugin/kubernetes-exec-transmitter.spec.ts @@ -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); +}); diff --git a/packages/main/src/plugin/kubernetes-exec-transmitter.ts b/packages/main/src/plugin/kubernetes-exec-transmitter.ts new file mode 100644 index 00000000000..76e40423041 --- /dev/null +++ b/packages/main/src/plugin/kubernetes-exec-transmitter.ts @@ -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); + } + } +} diff --git a/packages/preload/src/index.ts b/packages/preload/src/index.ts index e2b885df5b5..06012267942 100644 --- a/packages/preload/src/index.ts +++ b/packages/preload/src/index.ts @@ -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 => { + kubernetesCallbackId++; + kubernetesCallbackMap.set(kubernetesCallbackId, { onStdOut, onStdErr, onClose }); + return ipcInvoke('kubernetes-client:execIntoContainer', podName, containerName, kubernetesCallbackId); + }, + ); + + contextBridge.exposeInMainWorld('kubernetesExecSend', async (dataId: number, content: string): Promise => { + 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 => { diff --git a/packages/renderer/src/lib/pod/KubernetesTerminal.spec.ts b/packages/renderer/src/lib/pod/KubernetesTerminal.spec.ts new file mode 100644 index 00000000000..0c586b8fd3e --- /dev/null +++ b/packages/renderer/src/lib/pod/KubernetesTerminal.spec.ts @@ -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); +}); diff --git a/packages/renderer/src/lib/pod/KubernetesTerminal.svelte b/packages/renderer/src/lib/pod/KubernetesTerminal.svelte new file mode 100644 index 00000000000..9dd073384bf --- /dev/null +++ b/packages/renderer/src/lib/pod/KubernetesTerminal.svelte @@ -0,0 +1,156 @@ + + +
diff --git a/packages/renderer/src/lib/pod/KubernetesTerminalBrowser.svelte b/packages/renderer/src/lib/pod/KubernetesTerminalBrowser.svelte new file mode 100644 index 00000000000..8c812538740 --- /dev/null +++ b/packages/renderer/src/lib/pod/KubernetesTerminalBrowser.svelte @@ -0,0 +1,86 @@ + + +
+ +
+ {#if pod.containers.length > 1} + + {:else} + {currentContainerName} + {/if} +
+
+ +{#key key} + {#if terminalService.hasTerminal(pod.name, currentContainerName) && currentContainerStatus.get(currentContainerName) === 'running'} + + {/if} +{/key} + +