From 0cbf90e8649cb599dcf879e84f4cc351355ad2cd Mon Sep 17 00:00:00 2001 From: peterbom Date: Wed, 1 Nov 2023 21:16:17 +1300 Subject: [PATCH] TCP Dump (#281) --------- Co-authored-by: Tatsinnit --- package.json | 13 + .../aksTCPCollection/tcpDumpCollection.ts | 66 +++ src/commands/utils/kubectl.ts | 60 ++- src/extension.ts | 2 + src/panels/TcpDumpPanel.ts | 484 ++++++++++++++++++ .../webviewDefinitions/tcpDump.ts | 70 +++ src/webview-contract/webviewTypes.ts | 4 +- .../src/InspektorGadget/NewTraceDialog.tsx | 2 +- webview-ui/src/TCPDump/TcpDump.module.css | 38 ++ webview-ui/src/TCPDump/TcpDump.tsx | 175 +++++++ webview-ui/src/TCPDump/state.ts | 178 +++++++ .../NodeSelector.tsx | 0 webview-ui/src/main.tsx | 4 +- webview-ui/src/manualTest/main.tsx | 4 +- webview-ui/src/manualTest/tcpDumpTests.tsx | 156 ++++++ 15 files changed, 1244 insertions(+), 12 deletions(-) create mode 100644 src/commands/aksTCPCollection/tcpDumpCollection.ts create mode 100644 src/panels/TcpDumpPanel.ts create mode 100644 src/webview-contract/webviewDefinitions/tcpDump.ts create mode 100644 webview-ui/src/TCPDump/TcpDump.module.css create mode 100644 webview-ui/src/TCPDump/TcpDump.tsx create mode 100644 webview-ui/src/TCPDump/state.ts rename webview-ui/src/{InspektorGadget => components}/NodeSelector.tsx (100%) create mode 100644 webview-ui/src/manualTest/tcpDumpTests.tsx diff --git a/package.json b/package.json index 274aff787..828cd027c 100644 --- a/package.json +++ b/package.json @@ -243,6 +243,10 @@ { "command": "aks.aksRunKubectlCommands", "title": "Run Kubectl Commands" + }, + { + "command": "aks.aksTCPDump", + "title": "Collect TCP Dumps" } ], "menus": { @@ -305,6 +309,11 @@ "command": "aks.aksInspektorGadgetShow", "when": "view == kubernetes.cloudExplorer && viewItem =~ /aks\\.cluster/i || view == extension.vsKubernetesExplorer && viewItem =~ /vsKubernetes\\.\\w*cluster$/i", "group": "9@4" + }, + { + "command": "aks.aksTCPDump", + "when": "view == kubernetes.cloudExplorer && viewItem =~ /aks\\.cluster/i || view == extension.vsKubernetesExplorer && viewItem =~ /vsKubernetes\\.\\w*cluster$/i", + "group": "9@5" } ], "aks.createClusterSubMenu": [ @@ -396,6 +405,10 @@ { "id": "aks.createClusterSubMenu", "label": "Create Cluster" + }, + { + "id": "aks.tcpDataCollectionSubMenu", + "label": "Collect TCP Dump" } ] }, diff --git a/src/commands/aksTCPCollection/tcpDumpCollection.ts b/src/commands/aksTCPCollection/tcpDumpCollection.ts new file mode 100644 index 000000000..fb45aa9bf --- /dev/null +++ b/src/commands/aksTCPCollection/tcpDumpCollection.ts @@ -0,0 +1,66 @@ +import * as vscode from 'vscode'; +import * as k8s from 'vscode-kubernetes-tools-api'; +import { IActionContext } from "@microsoft/vscode-azext-utils"; +import { getKubernetesClusterInfo } from '../utils/clusters'; +import { getExtension } from '../utils/host'; +import { Errorable, failed, map as errmap } from '../utils/errorable'; +import * as tmpfile from '../utils/tempfile'; +import { TcpDumpDataProvider, TcpDumpPanel } from '../../panels/TcpDumpPanel'; +import { getVersion, invokeKubectlCommand } from '../utils/kubectl'; + +export async function aksTCPDump(_context: IActionContext, target: any) { + const kubectl = await k8s.extension.kubectl.v1; + const cloudExplorer = await k8s.extension.cloudExplorer.v1; + const clusterExplorer = await k8s.extension.clusterExplorer.v1; + + if (!kubectl.available) { + vscode.window.showWarningMessage(`Kubectl is unavailable.`); + return; + } + + if (!cloudExplorer.available) { + vscode.window.showWarningMessage(`Cloud explorer is unavailable.`); + return; + } + + if (!clusterExplorer.available) { + vscode.window.showWarningMessage(`Cluster explorer is unavailable.`); + return; + } + + const clusterInfo = await getKubernetesClusterInfo(target, cloudExplorer, clusterExplorer); + if (failed(clusterInfo)) { + vscode.window.showErrorMessage(clusterInfo.error); + return; + } + + const extension = getExtension(); + if (failed(extension)) { + vscode.window.showErrorMessage(extension.error); + return; + } + + const kubeConfigFile = await tmpfile.createTempFile(clusterInfo.result.kubeconfigYaml, "yaml"); + const linuxNodesList = await getLinuxNodes(kubectl, kubeConfigFile.filePath); + if (failed(linuxNodesList)) { + vscode.window.showErrorMessage(linuxNodesList.error); + return; + } + + const kubectlVersion = await getVersion(kubectl, kubeConfigFile.filePath); + if (failed(kubectlVersion)) { + vscode.window.showErrorMessage(kubectlVersion.error); + return; + } + + const dataProvider = new TcpDumpDataProvider(kubectl, kubeConfigFile.filePath, kubectlVersion.result, clusterInfo.result.name, linuxNodesList.result); + const panel = new TcpDumpPanel(extension.result.extensionUri); + + panel.show(dataProvider, kubeConfigFile); +} + +async function getLinuxNodes(kubectl: k8s.APIAvailable, kubeConfigFile: string): Promise> { + const command = `get node -l kubernetes.io/os=linux --no-headers -o custom-columns=":metadata.name"`; + const commandResult = await invokeKubectlCommand(kubectl, kubeConfigFile, command); + return errmap(commandResult, sr => sr.stdout.trim().split("\n")); +} \ No newline at end of file diff --git a/src/commands/utils/kubectl.ts b/src/commands/utils/kubectl.ts index f1f0908cd..88417899a 100644 --- a/src/commands/utils/kubectl.ts +++ b/src/commands/utils/kubectl.ts @@ -8,22 +8,66 @@ export enum NonZeroExitCodeBehaviour { Fail } -export async function invokeKubectlCommand(kubectl: APIAvailable, kubeConfigFile: string, command: string, exitCodeBehaviour?: NonZeroExitCodeBehaviour): Promise> { - try { +type KubeconfigCommandConfig = { + plainCommand: string, + commandWithKubeconfig: string, + exitCodeBehaviour: NonZeroExitCodeBehaviour +}; + +export type K8sVersion = { + major: string, + minor: string, + gitVersion: string, + buildDate: string +}; + +export type KubectlVersion = { + clientVersion: K8sVersion, + serverVersion: K8sVersion +}; + +export function getVersion(kubectl: APIAvailable, kubeConfigFile: string): Promise> { + return getKubectlJsonResult(kubectl, kubeConfigFile, "version -o json"); +} + +export async function getExecOutput(kubectl: APIAvailable, kubeConfigFile: string, namespace: string, pod: string, podCommand: string): Promise> { + const plainCommand = `exec -n ${namespace} ${pod} -- ${podCommand}`; + const config: KubeconfigCommandConfig = { + plainCommand, + // Note: kubeconfig is the first argument because it needs to be part of the kubectl args, not the exec command's args. + commandWithKubeconfig: `--kubeconfig="${kubeConfigFile}" ${plainCommand}`, + // Always fail for non-zero exit code. + exitCodeBehaviour: NonZeroExitCodeBehaviour.Fail + }; + + return invokeKubectlCommandInternal(kubectl, config); +} + +export function invokeKubectlCommand(kubectl: APIAvailable, kubeConfigFile: string, command: string, exitCodeBehaviour?: NonZeroExitCodeBehaviour): Promise> { + const config: KubeconfigCommandConfig = { + plainCommand: command, // Note: kubeconfig is the last argument because kubectl plugins will not work with kubeconfig in start. - const shellResult = await kubectl.api.invokeCommand(`${command} --kubeconfig="${kubeConfigFile}"`); + commandWithKubeconfig: `${command} --kubeconfig="${kubeConfigFile}"`, + exitCodeBehaviour: (exitCodeBehaviour === undefined) ? NonZeroExitCodeBehaviour.Fail : NonZeroExitCodeBehaviour.Succeed + }; + + return invokeKubectlCommandInternal(kubectl, config); +} + +async function invokeKubectlCommandInternal(kubectl: APIAvailable, config: KubeconfigCommandConfig): Promise> { + try { + const shellResult = await kubectl.api.invokeCommand(config.commandWithKubeconfig); if (shellResult === undefined) { - return { succeeded: false, error: `Failed to run kubectl command "${command}"` }; + return { succeeded: false, error: `Failed to run command "kubectl ${config.plainCommand}"` }; } - exitCodeBehaviour = (exitCodeBehaviour === undefined) ? NonZeroExitCodeBehaviour.Fail : NonZeroExitCodeBehaviour.Succeed; - if (shellResult.code !== 0 && exitCodeBehaviour === NonZeroExitCodeBehaviour.Fail) { - return { succeeded: false, error: `Kubectl returned error ${shellResult.code} for ${command}\nError: ${shellResult.stderr}` }; + if (shellResult.code !== 0 && config.exitCodeBehaviour === NonZeroExitCodeBehaviour.Fail) { + return { succeeded: false, error: `The command "kubectl ${config.plainCommand}" returned status code ${shellResult.code}\nError: ${shellResult.stderr}` }; } return { succeeded: true, result: shellResult }; } catch (e) { - return { succeeded: false, error: `Error running kubectl command "${command}": ${getErrorMessage(e)}` }; + return { succeeded: false, error: `Error running "kubectl ${config.plainCommand}":\n${getErrorMessage(e)}` }; } } diff --git a/src/extension.ts b/src/extension.ts index 882a7d5bd..1b29303e1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -32,6 +32,7 @@ import { aksInspektorGadgetShow } from './commands/aksInspektorGadget/aksInspekt import aksCreateCluster from './commands/aksCreateCluster/aksCreateCluster'; import aksAbortLastOperation from './commands/aksAbortLastOperation/aksAbortLastOperation'; import aksReconcileCluster from './commands/aksReconcileCluster/aksReconcileCluster'; +import { aksTCPDump } from './commands/aksTCPCollection/tcpDumpCollection'; export async function activate(context: vscode.ExtensionContext) { const cloudExplorer = await k8s.extension.cloudExplorer.v1; @@ -77,6 +78,7 @@ export async function activate(context: vscode.ExtensionContext) { registerCommandWithTelemetry('aks.aksReconcileCluster', aksReconcileCluster); registerCommandWithTelemetry('aks.aksInspektorGadgetShow', aksInspektorGadgetShow); registerCommandWithTelemetry('aks.createCluster', aksCreateCluster); + registerCommandWithTelemetry('aks.aksTCPDump', aksTCPDump); await registerAzureServiceNodes(context); diff --git a/src/panels/TcpDumpPanel.ts b/src/panels/TcpDumpPanel.ts new file mode 100644 index 000000000..b37fdc7ae --- /dev/null +++ b/src/panels/TcpDumpPanel.ts @@ -0,0 +1,484 @@ +import { platform } from "os"; +import { relative } from "path"; +import { Uri, commands, window, workspace } from "vscode"; +import * as k8s from 'vscode-kubernetes-tools-api'; +import * as semver from 'semver'; +import { failed, map as errmap, Errorable } from "../commands/utils/errorable"; +import { MessageHandler, MessageSink } from "../webview-contract/messaging"; +import { BasePanel, PanelDataProvider } from "./BasePanel"; +import { KubectlVersion, getExecOutput, invokeKubectlCommand } from "../commands/utils/kubectl"; +import { CompletedCapture, InitialState, ToVsCodeMsgDef, ToWebViewMsgDef } from "../webview-contract/webviewDefinitions/tcpDump"; +import { withOptionalTempFile } from "../commands/utils/tempfile"; + +const debugPodNamespace = "default"; +const tcpDumpCommandBase = "tcpdump --snapshot-length=0 -vvv"; +const captureDir = "/tmp"; +const captureFilePrefix = "vscodenodecap_"; +const captureFileBasePath = `${captureDir}/${captureFilePrefix}`; +const captureFilePathRegex = `${captureFileBasePath.replace(/\//g, '\\$&')}(.*)\.cap`; // Matches the part of the filename after the prefix + +function getPodName(node: string) { + return `debug-${node}`; +} + +function getTcpDumpCommand(capture: string): string { + return `${tcpDumpCommandBase} -w ${captureFileBasePath}${capture}.cap`; +} + +function getCaptureFromCommand(command: string, commandWithArgs: string): string | null { + if (command !== "tcpdump") return null; + if (!commandWithArgs.startsWith(tcpDumpCommandBase)) return null; + const fileMatch = commandWithArgs.match(new RegExp(`\-w ${captureFilePathRegex}`)); + return fileMatch && fileMatch[1]; +} + +function getCaptureFromFilePath(filePath: string): string | null { + const fileMatch = filePath.match(new RegExp(captureFilePathRegex)); + if (!fileMatch) return null; + return fileMatch && fileMatch[1]; +} + +export class TcpDumpPanel extends BasePanel<"tcpDump"> { + constructor(extensionUri: Uri) { + super(extensionUri, "tcpDump", { + checkNodeStateResponse: null, + startDebugPodResponse: null, + deleteDebugPodResponse: null, + startCaptureResponse: null, + stopCaptureResponse: null, + downloadCaptureFileResponse: null + }); + } +} + +export class TcpDumpDataProvider implements PanelDataProvider<"tcpDump"> { + constructor( + readonly kubectl: k8s.APIAvailable, + readonly kubeConfigFilePath: string, + readonly kubectlVersion: KubectlVersion, + readonly clusterName: string, + readonly linuxNodesList: string[] + ) { } + + getTitle(): string { + return `TCP Capture on ${this.clusterName}`; + } + + getInitialState(): InitialState { + return { + clusterName: this.clusterName, + allNodes: this.linuxNodesList, + }; + } + + getMessageHandler(webview: MessageSink): MessageHandler { + return { + checkNodeState: args => this._handleCheckNodeState(args.node, webview), + startDebugPod: args => this._handleStartDebugPod(args.node, webview), + deleteDebugPod: args => this._handleDeleteDebugPod(args.node, webview), + startCapture: args => this._handleStartCapture(args.node, args.capture, webview), + stopCapture: args => this._handleStopCapture(args.node, args.capture, webview), + downloadCaptureFile: args => this._handleDownloadCaptureFile(args.node, args.capture, webview), + openFolder: args => this._handleOpenFolder(args) + }; + } + + private async _handleCheckNodeState(node: string, webview: MessageSink) { + const podNames = await this._getPodNames(); + if (failed(podNames)) { + webview.postCheckNodeStateResponse({ + node, + succeeded: false, + errorMessage: `Failed to get debug pod names:\n${podNames.error}`, + isDebugPodRunning: false, + runningCapture: null, + completedCaptures: [] + }); + return; + } + + const isDebugPodRunning = podNames.result.includes(getPodName(node)); + if (!isDebugPodRunning) { + webview.postCheckNodeStateResponse({ + node, + succeeded: true, + errorMessage: null, + isDebugPodRunning, + runningCapture: null, + completedCaptures: [] + }); + return; + } + + const waitResult = await this._waitForPodReady(node); + if (failed(waitResult)) { + webview.postCheckNodeStateResponse({ + node, + succeeded: false, + errorMessage: `Pod ${getPodName(node)} is not ready:\n${waitResult.error}`, + isDebugPodRunning, + runningCapture: null, + completedCaptures: [] + }); + return; + } + + const runningCaptureProcs = await this._getRunningCaptures(node); + if (failed(runningCaptureProcs)) { + webview.postCheckNodeStateResponse({ + node, + succeeded: false, + errorMessage: `Failed to read running captures:\n${runningCaptureProcs.error}`, + isDebugPodRunning, + runningCapture: null, + completedCaptures: [] + }); + return; + } + + const runningCapture = runningCaptureProcs.result.length > 0 ? runningCaptureProcs.result[0].capture : null; + const completedCaptures = await this._getCompletedCaptures(node, runningCaptureProcs.result.map(p => p.capture)); + if (failed(completedCaptures)) { + webview.postCheckNodeStateResponse({ + node, + succeeded: false, + errorMessage: `Failed to read completed captures:\n${completedCaptures.error}`, + isDebugPodRunning, + runningCapture, + completedCaptures: [] + }); + return; + } + + webview.postCheckNodeStateResponse({ + node, + succeeded: true, + errorMessage: null, + isDebugPodRunning, + runningCapture, + completedCaptures: completedCaptures.result + }); + } + + private async _handleStartDebugPod(node: string, webview: MessageSink) { + const createPodYaml = ` +apiVersion: v1 +kind: Pod +metadata: + name: ${getPodName(node)} + namespace: ${debugPodNamespace} +spec: + containers: + - args: ["-c", "sleep infinity"] + command: ["/bin/sh"] + image: mcr.microsoft.com/dotnet/runtime-deps:6.0 + imagePullPolicy: IfNotPresent + name: debug + resources: {} + securityContext: + privileged: true + runAsUser: 0 + volumeMounts: + - mountPath: /host + name: host-volume + volumes: + - name: host-volume + hostPath: + path: / + dnsPolicy: ClusterFirst + nodeSelector: + kubernetes.io/hostname: ${node} + restartPolicy: Never + securityContext: {} + hostIPC: true + hostNetwork: true + hostPID: true`; + + const applyResult = await withOptionalTempFile(createPodYaml, "YAML", async podSpecFile => { + const command = `apply -f ${podSpecFile}`; + return await invokeKubectlCommand(this.kubectl, this.kubeConfigFilePath, command); + }); + + if (failed(applyResult)) { + webview.postStartDebugPodResponse({ + node, + succeeded: false, + errorMessage: `Unable to create debug pod:\n${applyResult.error}` + }); + return; + } + + const waitResult = await this._waitForPodReady(node); + if (failed(waitResult)) { + webview.postStartDebugPodResponse({ + node, + succeeded: false, + errorMessage: `Pod ${getPodName(node)} is not ready:\n${waitResult.error}` + }); + return; + } + + const installResult = await this._installDebugTools(node); + if (failed(installResult)) { + webview.postStartDebugPodResponse({ + node, + succeeded: false, + errorMessage: `Installing debug tools failed on ${getPodName(node)}:\n${installResult.error}` + }); + return; + } + + webview.postStartDebugPodResponse({ + node, + succeeded: true, + errorMessage: null + }); + } + + private async _handleDeleteDebugPod(node: string, webview: MessageSink) { + const command = `delete pod -n ${debugPodNamespace} ${getPodName(node)}`; + const output = await invokeKubectlCommand(this.kubectl, this.kubeConfigFilePath, command); + if (failed(output)) { + webview.postDeleteDebugPodResponse({ + node, + succeeded: false, + errorMessage: `Failed to delete ${getPodName(node)}:\n${output.error}` + }); + return; + } + + const waitResult = await this._waitForPodDeleted(node); + if (failed(waitResult)) { + webview.postStartDebugPodResponse({ + node, + succeeded: false, + errorMessage: `Pod ${getPodName(node)} was not deleted:\n${waitResult.error}` + }); + return; + } + + webview.postDeleteDebugPodResponse({ + node, + succeeded: true, + errorMessage: null + }); + } + + private async _handleStartCapture(node: string, capture: string, webview: MessageSink) { + const podCommand = `/bin/sh -c "${getTcpDumpCommand(capture)} 1>/dev/null 2>&1 &"`; + const output = await getExecOutput(this.kubectl, this.kubeConfigFilePath, debugPodNamespace, getPodName(node), podCommand); + webview.postStartCaptureResponse({ + node, + succeeded: output.succeeded, + errorMessage: failed(output) ? output.error : null + }); + } + + private async _handleStopCapture(node: string, capture: string, webview: MessageSink) { + const runningCaptures = await this._getRunningCaptures(node); + if (failed(runningCaptures)) { + webview.postStopCaptureResponse({ + node, + succeeded: false, + errorMessage: `Failed to determine running captures:\n${runningCaptures.error}`, + capture: null + }); + return; + } + + const captureProcess = runningCaptures.result.find(p => p.capture === capture); + if (!captureProcess) { + webview.postStopCaptureResponse({ + node, + succeeded: false, + errorMessage: `Unable to find running capture ${capture}. Found: ${runningCaptures.result.map(p => p.capture).join(",")}`, + capture: null + }); + return; + } + + const podCommand = `/bin/sh -c "kill ${captureProcess.pid}"`; + const killOutput = await getExecOutput(this.kubectl, this.kubeConfigFilePath, debugPodNamespace, getPodName(node), podCommand); + if (failed(killOutput)) { + webview.postStopCaptureResponse({ + node, + succeeded: false, + errorMessage: `Failed to kill tcpdump process ${captureProcess.pid}:\n${killOutput.error}`, + capture: null + }); + return; + } + + const completedCaptures = await this._getCompletedCaptures(node, []); + if (failed(completedCaptures)) { + webview.postStopCaptureResponse({ + node, + succeeded: false, + errorMessage: `Failed to read completed captures:\n${completedCaptures.error}`, + capture: null + }); + return; + } + + const stoppedCapture = completedCaptures.result.find(c => c.name === capture); + if (!stoppedCapture) { + webview.postStopCaptureResponse({ + node, + succeeded: false, + errorMessage: `Cannot find capture file for ${capture}`, + capture: null + }); + return; + } + + webview.postStopCaptureResponse({ + node, + succeeded: true, + errorMessage: null, + capture: stoppedCapture + }); + } + + private async _handleDownloadCaptureFile(node: string, captureName: string, webview: MessageSink) { + const localCaptureUri = await window.showSaveDialog({ + defaultUri: Uri.file(`${captureName}.cap`), + filters: {"Capture Files": ['cap']}, + saveLabel: 'Download', + title: 'Download Capture File' + }); + + if (!localCaptureUri) { + return; + } + + const localCpPath = getLocalKubectlCpPath(localCaptureUri); + + // `kubectl cp` can fail with an EOF error for large files, and there's currently no good workaround: + // See: https://github.com/kubernetes/kubernetes/issues/60140 + // The best advice I can see is to use the 'retries' option if it is supported, and the + // 'request-timeout' option otherwise. + const clientVersion = this.kubectlVersion.clientVersion.gitVersion.replace(/^v/, ""); + const isRetriesOptionSupported = semver.parse(clientVersion) && semver.gte(clientVersion, "1.23.0"); + const cpEOFAvoidanceFlag = isRetriesOptionSupported ? "--retries 99" : "--request-timeout=10m"; + const command = `cp -n ${debugPodNamespace} ${getPodName(node)}:${captureFileBasePath}${captureName}.cap ${localCpPath} ${cpEOFAvoidanceFlag}`; + const output = await invokeKubectlCommand(this.kubectl, this.kubeConfigFilePath, command); + if (failed(output)) { + webview.postDownloadCaptureFileResponse({ + node, + captureName, + localCapturePath: localCaptureUri.fsPath, + succeeded: false, + errorMessage: `Failed to download ${captureName} to ${localCaptureUri.fsPath}:\n${output.error}` + }); + return; + } + + webview.postDownloadCaptureFileResponse({ + node, + captureName, + localCapturePath: localCaptureUri.fsPath, + succeeded: output.succeeded, + errorMessage: null + }); + } + + private _handleOpenFolder(path: string) { + commands.executeCommand('revealFileInOS', Uri.file(path)); + } + + private async _getPodNames(): Promise> { + const command = `get pod -n ${debugPodNamespace} --no-headers -o custom-columns=":metadata.name"`; + const output = await invokeKubectlCommand(this.kubectl, this.kubeConfigFilePath, command); + return errmap(output, sr => sr.stdout.trim().split("\n")); + } + + private async _waitForPodReady(node: string): Promise> { + const command = `wait pod -n ${debugPodNamespace} --for=condition=ready --timeout=300s ${getPodName(node)}`; + const output = await invokeKubectlCommand(this.kubectl, this.kubeConfigFilePath, command); + return errmap(output, _ => undefined); + } + + private async _waitForPodDeleted(node: string): Promise> { + const command = `wait pod -n ${debugPodNamespace} --for=delete --timeout=300s ${getPodName(node)}`; + const output = await invokeKubectlCommand(this.kubectl, this.kubeConfigFilePath, command); + return errmap(output, _ => undefined); + } + + private async _installDebugTools(node: string): Promise> { + const podCommand = `/bin/sh -c "apt-get update && apt-get install -y tcpdump procps"`; + const output = await getExecOutput(this.kubectl, this.kubeConfigFilePath, debugPodNamespace, getPodName(node), podCommand); + return errmap(output, _ => undefined); + } + + private async _getRunningCaptures(node: string): Promise> { + // List all processes without header columns, including PID, command and args (which contains the command) + const podCommand = "ps -e -o pid= -o comm= -o args="; + const output = await getExecOutput(this.kubectl, this.kubeConfigFilePath, debugPodNamespace, getPodName(node), podCommand); + return errmap(output, sr => sr.stdout.trim().split("\n").map(asProcess).filter(isTcpDump)); + + function asProcess(psOutputLine: string): Process { + const parts = psOutputLine.trim().split(/\s+/); + const pid = parseInt(parts[0]); + const command = parts[1]; + const args = parts.slice(2).join(' '); + const capture = getCaptureFromCommand(command, args); + const isTcpDump = capture !== null; + const process = {pid, command, args, isTcpDump, capture}; + return process; + } + } + + private async _getCompletedCaptures(node: string, runningCaptures: string[]): Promise> { + // Use 'find' rather than 'ls' (http://mywiki.wooledge.org/ParsingLs) + const podCommand = `find ${captureDir} -type f -name ${captureFilePrefix}*.cap -printf "%p\\t%k\\n"`; + const output = await getExecOutput(this.kubectl, this.kubeConfigFilePath, debugPodNamespace, getPodName(node), podCommand); + return errmap(output, sr => sr.stdout.trim().split("\n").map(asCompletedCapture).filter(cap => !runningCaptures.some(c => cap.name === c))); + + function asCompletedCapture(findOutputLine: string): CompletedCapture { + const parts = findOutputLine.trim().split("\t"); + const filePath = parts[0]; + const sizeInKB = parseInt(parts[1]); + const name = getCaptureFromFilePath(filePath); + if (name === null) { + const errorMessage = `Error extracting capture name from ${findOutputLine}`; + window.showErrorMessage(errorMessage); + throw new Error(errorMessage); + } + + return {name, sizeInKB}; + } + } +} + +type Process = { + pid: number, + command: string, + args: string, + isTcpDump: boolean +}; + +type TcpDumpProcess = Process & { + isTcpDump: true, + capture: string +}; + +function isTcpDump(process: Process): process is TcpDumpProcess { + return process.isTcpDump; +} + +function getLocalKubectlCpPath(fileUri: Uri): string { + if (platform().toLowerCase() !== "win32") { + return fileUri.fsPath; + } + + // Use a relative path to work around Windows path issues: + // - https://github.com/kubernetes/kubernetes/issues/77310 + // - https://github.com/kubernetes/kubernetes/issues/110120 + // To use a relative path we need to know the current working directory. + // This should be `process.cwd()` but it actually seems to be that of the first workspace folder, if any exist. + // TODO: Investigate why, and look at alternative ways of getting the working directory, or working around + // the need to to this altogether by allowing absolute paths. + const workingDirectory = (workspace.workspaceFolders && workspace.workspaceFolders?.length > 0) ? workspace.workspaceFolders[0].uri.fsPath : process.cwd(); + + return relative(workingDirectory, fileUri.fsPath); +} \ No newline at end of file diff --git a/src/webview-contract/webviewDefinitions/tcpDump.ts b/src/webview-contract/webviewDefinitions/tcpDump.ts new file mode 100644 index 000000000..5e81417c8 --- /dev/null +++ b/src/webview-contract/webviewDefinitions/tcpDump.ts @@ -0,0 +1,70 @@ +import { WebviewDefinition } from "../webviewTypes"; + +export interface InitialState { + clusterName: string, + allNodes: string[] +} + +export type NodeName = string; +export type CaptureName = string; + +export type CompletedCapture = { + name: CaptureName, + sizeInKB: number +}; + +export type NodeCommand = { + node: NodeName +}; + +export type NodeCaptureCommand = NodeCommand & { + capture: CaptureName +}; + +export type CommandResult = { + succeeded: boolean, + errorMessage: string | null +}; + +export type NodeCommandResult = CommandResult & { + node: NodeName +}; + +export type NodeCaptureStopResult = NodeCommandResult & { + capture: CompletedCapture | null +}; + +export type NodeCaptureCommandResult = NodeCommandResult & { + captureName: CaptureName +}; + +export type NodeCheckResult = NodeCommandResult & { + isDebugPodRunning: boolean, + runningCapture: CaptureName | null, + completedCaptures: CompletedCapture[] +}; + +export type NodeCaptureDownloadResult = NodeCaptureCommandResult & { + localCapturePath: string +}; + +export type ToVsCodeMsgDef = { + checkNodeState: NodeCommand, + startDebugPod: NodeCommand, + startCapture: NodeCaptureCommand, + stopCapture: NodeCaptureCommand, + downloadCaptureFile: NodeCaptureCommand, + deleteDebugPod: NodeCommand, + openFolder: string +}; + +export type ToWebViewMsgDef = { + checkNodeStateResponse: NodeCheckResult, + startDebugPodResponse: NodeCommandResult, + startCaptureResponse: NodeCommandResult, + stopCaptureResponse: NodeCaptureStopResult, + downloadCaptureFileResponse: NodeCaptureDownloadResult, + deleteDebugPodResponse: NodeCommandResult +}; + +export type TCPDumpDefinition = WebviewDefinition; diff --git a/src/webview-contract/webviewTypes.ts b/src/webview-contract/webviewTypes.ts index 470d6ef43..7ac06fde0 100644 --- a/src/webview-contract/webviewTypes.ts +++ b/src/webview-contract/webviewTypes.ts @@ -7,6 +7,7 @@ import { InspektorGadgetDefinition } from "./webviewDefinitions/inspektorGadget" import { PeriscopeDefinition } from "./webviewDefinitions/periscope"; import { TestStyleViewerDefinition } from "./webviewDefinitions/testStyleViewer"; import { ASODefinition } from "./webviewDefinitions/azureServiceOperator"; +import { TCPDumpDefinition } from "./webviewDefinitions/tcpDump"; /** * Groups all the related types for a single webview. @@ -29,7 +30,8 @@ type AllWebviewDefinitions = { detector: DetectorDefinition, gadget: InspektorGadgetDefinition, kubectl: KubectlDefinition, - aso: ASODefinition + aso: ASODefinition, + tcpDump: TCPDumpDefinition }; type ContentIdLookup = { diff --git a/webview-ui/src/InspektorGadget/NewTraceDialog.tsx b/webview-ui/src/InspektorGadget/NewTraceDialog.tsx index 508c29d8c..81ce52037 100644 --- a/webview-ui/src/InspektorGadget/NewTraceDialog.tsx +++ b/webview-ui/src/InspektorGadget/NewTraceDialog.tsx @@ -8,7 +8,7 @@ import { isLoaded, isNotLoaded } from "../utilities/lazy"; import { TraceItemSortSelector } from "./TraceItemSortSelector"; import { GadgetConfiguration, configuredGadgetResources, getGadgetMetadata } from "./helpers/gadgets"; import { GadgetSelector } from "./GadgetSelector"; -import { NodeSelector } from "./NodeSelector"; +import { NodeSelector } from "../components/NodeSelector"; import { GadgetCategory, GadgetExtraProperties, SortSpecifier, toExtraPropertyObject } from "./helpers/gadgets/types"; import { NamespaceSelection } from "../../../src/webview-contract/webviewDefinitions/inspektorGadget"; import { EventHandlers } from "../utilities/state"; diff --git a/webview-ui/src/TCPDump/TcpDump.module.css b/webview-ui/src/TCPDump/TcpDump.module.css new file mode 100644 index 000000000..a70bb22b7 --- /dev/null +++ b/webview-ui/src/TCPDump/TcpDump.module.css @@ -0,0 +1,38 @@ +.content { + display: grid; + grid-template-columns: 6rem 50rem; + grid-gap: 1rem; + align-items: center; +} + +.content .label { + grid-column: 1 / 2; +} + +.content .control { + grid-column: 2 / 3; +} + +.content .controlDropdown { + grid-column: 2 / 3; + max-width: 30rem; +} + +.content .controlButton { + grid-column: 2 / 3; + max-width: 8rem; +} + +table.capturelist { + border-collapse: collapse; + width: 100%; +} + +table.capturelist th { + height: 50px; + text-align: left; +} + +table.capturelist td { + padding: 5px; +} \ No newline at end of file diff --git a/webview-ui/src/TCPDump/TcpDump.tsx b/webview-ui/src/TCPDump/TcpDump.tsx new file mode 100644 index 000000000..c98951e19 --- /dev/null +++ b/webview-ui/src/TCPDump/TcpDump.tsx @@ -0,0 +1,175 @@ +import { CaptureName, InitialState } from "../../../src/webview-contract/webviewDefinitions/tcpDump"; +import styles from "./TcpDump.module.css"; +import { useEffect } from "react"; +import { VSCodeButton, VSCodeDivider, VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react"; +import { getStateManagement } from "../utilities/state"; +import { CaptureStatus, NodeStatus, stateUpdater, vscode } from "./state"; +import { NodeSelector } from "../components/NodeSelector"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faCopy, faDownload, faFolderOpen, faPlay, faPlus, faSpinner, faStop, faTrash } from "@fortawesome/free-solid-svg-icons"; + +export function TcpDump(initialState: InitialState) { + const {state, eventHandlers, vsCodeMessageHandlers} = getStateManagement(stateUpdater, initialState); + + useEffect(() => { + vscode.subscribeToMessages(vsCodeMessageHandlers); + + if (state.selectedNode && state.nodeStates[state.selectedNode].status === NodeStatus.Unknown) { + vscode.postCheckNodeState({node: state.selectedNode}); + eventHandlers.onSetCheckingNodeState({node: state.selectedNode}); + } + }); + + function handleCreateDebugPod() { + if (!state.selectedNode) return; + const nodeState = state.nodeStates[state.selectedNode]; + if (nodeState.status !== NodeStatus.Clean) return; + vscode.postStartDebugPod({node: state.selectedNode}); + eventHandlers.onCreatingNodeDebugPod({node: state.selectedNode}); + } + + function handleRemoveDebugPod() { + if (!state.selectedNode) return; + const nodeState = state.nodeStates[state.selectedNode]; + if (nodeState.status !== NodeStatus.DebugPodRunning) return; + vscode.postDeleteDebugPod({node: state.selectedNode}); + eventHandlers.onDeletingNodeDebugPod({node: state.selectedNode}); + } + + function handleStartCapture() { + if (!state.selectedNode) return; + const nodeState = state.nodeStates[state.selectedNode]; + if (nodeState.status !== NodeStatus.DebugPodRunning) return; + const captureName = new Date().toISOString().slice(0,-5).replaceAll(":", "-").replace("T", "_"); + vscode.postStartCapture({node: state.selectedNode, capture: captureName}); + eventHandlers.onStartingNodeCapture({node: state.selectedNode, capture: captureName}); + } + + function handleStopCapture() { + if (!state.selectedNode) return; + const nodeState = state.nodeStates[state.selectedNode]; + if (nodeState.status !== NodeStatus.CaptureRunning) return; + if (!nodeState.currentCaptureName) return; + vscode.postStopCapture({node: state.selectedNode, capture: nodeState.currentCaptureName}); + eventHandlers.onStoppingNodeCapture({node: state.selectedNode}); + } + + function handleStartDownload(captureName: CaptureName) { + if (!state.selectedNode) return; + vscode.postDownloadCaptureFile({node: state.selectedNode, capture: captureName}); + eventHandlers.onDownloadingNodeCapture({node: state.selectedNode, capture: captureName}); + } + + function handleCopyDownloadPathClick(path: string) { + navigator.clipboard.writeText(path); + } + + function handleOpenFolderClick(path: string) { + vscode.postOpenFolder(path); + } + + const nodeState = state.selectedNode ? state.nodeStates[state.selectedNode] : null; + function hasStatus(...statuses: NodeStatus[]): boolean { + return nodeState !== null && statuses.includes(nodeState.status); + } + + return ( + <> +
+

TCP Capture on {state.clusterName}

+
+ + + +
+ + + + {hasStatus(NodeStatus.Checking) && +
+ + Checking Node +
+ } + + + {hasStatus(NodeStatus.Clean, NodeStatus.CreatingDebugPod) && + + {hasStatus(NodeStatus.CreatingDebugPod) && } + {!hasStatus(NodeStatus.CreatingDebugPod) && } + Create + + } + {hasStatus(NodeStatus.DebugPodRunning, NodeStatus.DeletingDebugPod, NodeStatus.CaptureStarting, NodeStatus.CaptureRunning, NodeStatus.CaptureStopping) && + + {hasStatus(NodeStatus.DeletingDebugPod) && } + {!hasStatus(NodeStatus.DeletingDebugPod) && } + Delete + + } + + + {hasStatus(NodeStatus.DebugPodRunning, NodeStatus.CaptureStarting) && + + {hasStatus(NodeStatus.CaptureStarting) && } + {!hasStatus(NodeStatus.CaptureStarting) && } + Start + + } + {hasStatus(NodeStatus.CaptureRunning, NodeStatus.CaptureStopping) && + + {hasStatus(NodeStatus.CaptureStopping) && } + {!hasStatus(NodeStatus.CaptureStopping) && } + Stop + + } +
+ +

Completed Captures

+ {hasStatus(NodeStatus.DebugPodRunning, NodeStatus.CaptureStarting, NodeStatus.CaptureRunning, NodeStatus.CaptureStopping) && nodeState && nodeState.completedCaptures.length > 0 && ( + + + + + + + + + + {nodeState.completedCaptures.map(c => ( + + + + + + ))} + +
NameSize (kB)Local Path
{c.name}{c.sizeInKB} + {!c.downloadedFilePath && + handleStartDownload(c.name)} disabled={c.status !== CaptureStatus.Completed} appearance="secondary"> + {c.status === CaptureStatus.Downloading && } + {c.status === CaptureStatus.Completed && } + Download + + } + + {c.downloadedFilePath && +
+ {c.downloadedFilePath} +   + handleCopyDownloadPathClick(c.downloadedFilePath!)} /> + handleOpenFolderClick(c.downloadedFilePath!)} /> +
+ } +
+ )} + + {nodeState?.errorMessage && + <> + +
{nodeState?.errorMessage}
+ + } + + );; +} \ No newline at end of file diff --git a/webview-ui/src/TCPDump/state.ts b/webview-ui/src/TCPDump/state.ts new file mode 100644 index 000000000..996a18ef4 --- /dev/null +++ b/webview-ui/src/TCPDump/state.ts @@ -0,0 +1,178 @@ +import { InitialState, NodeName, NodeCommandResult, CaptureName, NodeCheckResult, NodeCaptureDownloadResult, NodeCaptureStopResult } from "../../../src/webview-contract/webviewDefinitions/tcpDump"; +import { replaceItem } from "../utilities/array"; +import { WebviewStateUpdater } from "../utilities/state"; +import { getWebviewMessageContext } from "../utilities/vscode"; + +export enum NodeStatus { + Unknown, + Checking, + Clean, + CreatingDebugPod, + DeletingDebugPod, + DebugPodRunning, + CaptureStarting, + CaptureRunning, + CaptureStopping +} + +export enum CaptureStatus { + Completed, + Downloading, + Downloaded +} + +type NodeState = { + status: NodeStatus, + errorMessage: string | null, + currentCaptureName: CaptureName | null, + completedCaptures: NodeCapture[] +}; + +type NodeStates = {[name: NodeName]: NodeState}; + +type NodeCapture = { + name: CaptureName, + status: CaptureStatus, + sizeInKB: number, + downloadedFilePath: string | null +} + +export type TcpDumpState = InitialState & { + selectedNode: NodeName | null, + nodeStates: NodeStates +}; + +export type EventDef = { + setSelectedNode: string | null, + setCheckingNodeState: {node: NodeName}, + creatingNodeDebugPod: {node: NodeName}, + deletingNodeDebugPod: {node: NodeName}, + startingNodeCapture: {node: NodeName, capture: CaptureName}, + stoppingNodeCapture: {node: NodeName}, + downloadingNodeCapture: {node: NodeName, capture: CaptureName} +}; + +export const stateUpdater: WebviewStateUpdater<"tcpDump", EventDef, TcpDumpState> = { + createState: initialState => ({ + ...initialState, + selectedNode: null, + nodeStates: {} + }), + vscodeMessageHandler: { + checkNodeStateResponse: (state, args) => ({...state, nodeStates: getNodeStatesFromCheck(state.nodeStates, args)}), + startDebugPodResponse: (state, args) => ({...state, nodeStates: getNodeStatesFromPodCreation(state.nodeStates, args)}), + deleteDebugPodResponse: (state, args) => ({...state, nodeStates: getNodeStatesFromPodDeletion(state.nodeStates, args)}), + startCaptureResponse: (state, args) => ({...state, nodeStates: getNodeStatesFromCaptureStartResult(state.nodeStates, args)}), + stopCaptureResponse: (state, args) => ({...state, nodeStates: getNodeStatesFromCaptureStopResult(state.nodeStates, args)}), + downloadCaptureFileResponse: (state, args) => ({...state, nodeStates: getNodeStatesFromDownloadResult(state.nodeStates, args)}) + }, + eventHandler: { + setSelectedNode: (state, node) => ({...state, selectedNode: node, nodeStates: ensureNodeStateExists(state.nodeStates, node)}), + setCheckingNodeState: (state, args) => ({...state, nodeStates: getNodeStatesFromStatus(state.nodeStates, args.node, NodeStatus.Checking)}), + creatingNodeDebugPod: (state, args) => ({...state, nodeStates: getNodeStatesFromStatus(state.nodeStates, args.node, NodeStatus.CreatingDebugPod)}), + deletingNodeDebugPod: (state, args) => ({...state, nodeStates: getNodeStatesFromStatus(state.nodeStates, args.node, NodeStatus.DeletingDebugPod)}), + startingNodeCapture: (state, args) => ({...state, nodeStates: getNodeStatesFromCaptureStarting(state.nodeStates, args.node, args.capture)}), + stoppingNodeCapture: (state, args) => ({...state, nodeStates: getNodeStatesFromStatus(state.nodeStates, args.node, NodeStatus.CaptureStopping)}), + downloadingNodeCapture: (state, args) => ({...state, nodeStates: getNodeStatesFromDownloadStarting(state.nodeStates, args.node, args.capture)}) + } +}; + +function ensureNodeStateExists(nodeStates: NodeStates, node: NodeName | null): NodeStates { + if (!node) return nodeStates; + const defaultNodeState: NodeState = {status: NodeStatus.Unknown, errorMessage: null, currentCaptureName: null, completedCaptures: []}; + return {[node]: defaultNodeState, ...nodeStates}; +} + +function getNodeStatesFromCheck(nodeStates: NodeStates, result: NodeCheckResult): NodeStates { + const status = + !result.isDebugPodRunning ? NodeStatus.Clean : + result.runningCapture === null ? NodeStatus.DebugPodRunning : + NodeStatus.CaptureRunning; + + const currentCaptureName = result.runningCapture; + + const completedCaptures = result.completedCaptures.map(c => ({ + name: c.name, + sizeInKB: c.sizeInKB, + status: CaptureStatus.Completed, + downloadedFilePath: null + })); + + const nodeState: NodeState = {...nodeStates[result.node], status, currentCaptureName, completedCaptures}; + return {...nodeStates, [result.node]: nodeState}; +} + +function getNodeStatesFromStatus(nodeStates: NodeStates, node: NodeName, newStatus: NodeStatus): NodeStates { + return updateNodeState(nodeStates, node, state => ({...state, status: newStatus})); +} + +function getNodeStatesFromPodCreation(nodeStates: NodeStates, result: NodeCommandResult): NodeStates { + const errorMessage = result.succeeded ? null : result.errorMessage; + const status = result.succeeded ? NodeStatus.DebugPodRunning : NodeStatus.Unknown; + return updateNodeState(nodeStates, result.node, state => ({...state, status, errorMessage})); +} + +function getNodeStatesFromPodDeletion(nodeStates: NodeStates, result: NodeCommandResult): NodeStates { + const errorMessage = result.succeeded ? null : result.errorMessage; + const status = result.succeeded ? NodeStatus.Clean : NodeStatus.Unknown; + const currentCaptureName = null; + const completedCaptures: NodeCapture[] = []; + return updateNodeState(nodeStates, result.node, state => ({...state, status, errorMessage, currentCaptureName, completedCaptures})); +} + +function getNodeStatesFromCaptureStarting(nodeStates: NodeStates, node: NodeName, capture: CaptureName): NodeStates { + return updateNodeState(nodeStates, node, state => ({...state, status: NodeStatus.CaptureStarting, currentCaptureName: capture})); +} + +function getNodeStatesFromCaptureStartResult(nodeStates: NodeStates, result: NodeCommandResult): NodeStates { + const errorMessage = result.succeeded ? null : result.errorMessage; + const status = result.succeeded ? NodeStatus.CaptureRunning : NodeStatus.DebugPodRunning; + return updateNodeState(nodeStates, result.node, state => { + const currentCaptureName = result.succeeded ? state.currentCaptureName : null; + return {...state, status, errorMessage, currentCaptureName}; + }); +} + +function getNodeStatesFromCaptureStopResult(nodeStates: NodeStates, result: NodeCaptureStopResult): NodeStates { + const errorMessage = result.succeeded ? null : result.errorMessage; + const status = result.succeeded ? NodeStatus.DebugPodRunning : NodeStatus.Unknown; + return updateNodeState(nodeStates, result.node, state => { + const newCapture: NodeCapture | null = result.succeeded && result.capture ? {name: result.capture.name, sizeInKB: result.capture.sizeInKB, status: CaptureStatus.Completed, downloadedFilePath: null} : null; + const completedCaptures: NodeCapture[] = newCapture ? [...state.completedCaptures, newCapture] : state.completedCaptures; + return {...state, status, errorMessage, completedCaptures}; + }); +} + +function getNodeStatesFromDownloadStarting(nodeStates: NodeStates, node: NodeName, captureName: CaptureName): NodeStates { + return updateCapture(nodeStates, node, captureName, c => ({...c, status: CaptureStatus.Downloading})) +} + +function getNodeStatesFromDownloadResult(nodeStates: NodeStates, result: NodeCaptureDownloadResult): NodeStates { + const errorMessage = result.succeeded ? null : result.errorMessage; + nodeStates = updateNodeState(nodeStates, result.node, state => ({...state, errorMessage})); + + const status = result.succeeded ? CaptureStatus.Downloaded : CaptureStatus.Completed; + const downloadedFilePath = result.succeeded ? result.localCapturePath : null; + nodeStates = updateCapture(nodeStates, result.node, result.captureName, c => ({...c, status, downloadedFilePath})); + + return nodeStates; +} + +function updateNodeState(nodeStates: NodeStates, node: NodeName, updater: (nodeState: NodeState) => NodeState) { + return {...nodeStates, [node]: updater(nodeStates[node])}; +} + +function updateCapture(nodeStates: NodeStates, node: NodeName, captureName: CaptureName, updater: (capture: NodeCapture) => NodeCapture): NodeStates { + const completedCaptures = replaceItem(nodeStates[node].completedCaptures, c => c.name === captureName, updater); + return updateNodeState(nodeStates, node, state => ({...state, completedCaptures})); +} + +export const vscode = getWebviewMessageContext<"tcpDump">({ + checkNodeState: null, + startDebugPod: null, + startCapture: null, + stopCapture: null, + downloadCaptureFile: null, + openFolder: null, + deleteDebugPod: null +}); \ No newline at end of file diff --git a/webview-ui/src/InspektorGadget/NodeSelector.tsx b/webview-ui/src/components/NodeSelector.tsx similarity index 100% rename from webview-ui/src/InspektorGadget/NodeSelector.tsx rename to webview-ui/src/components/NodeSelector.tsx diff --git a/webview-ui/src/main.tsx b/webview-ui/src/main.tsx index 57a98c99b..4cd569339 100644 --- a/webview-ui/src/main.tsx +++ b/webview-ui/src/main.tsx @@ -11,6 +11,7 @@ import { InspektorGadget } from "./InspektorGadget/InspektorGadget"; import { Kubectl } from "./Kubectl/Kubectl"; import { AzureServiceOperator } from "./AzureServiceOperator/AzureServiceOperator"; import { ClusterProperties } from "./ClusterProperties/ClusterProperties"; +import { TcpDump } from "./TCPDump/TcpDump"; // There are two modes of launching this application: // 1. Via the VS Code extension inside a Webview. @@ -44,7 +45,8 @@ function getVsCodeContent(): JSX.Element { detector: () => , gadget: () => , kubectl: () => , - aso: () => + aso: () => , + tcpDump: () => }; return rendererLookup[vscodeContentId](); diff --git a/webview-ui/src/manualTest/main.tsx b/webview-ui/src/manualTest/main.tsx index 193d89c62..d4bc6d629 100644 --- a/webview-ui/src/manualTest/main.tsx +++ b/webview-ui/src/manualTest/main.tsx @@ -13,6 +13,7 @@ import { ContentId } from "../../../src/webview-contract/webviewTypes"; import { Scenario } from "../utilities/manualTest"; import { getASOScenarios } from "./asoTests"; import { getClusterPropertiesScenarios } from "./clusterPropertiesTests"; +import { getTCPDumpScenarios } from "./tcpDumpTests"; // There are two modes of launching this application: // 1. Via the VS Code extension inside a Webview. @@ -35,7 +36,8 @@ const contentTestScenarios: Record = { detector: getDetectorScenarios(), gadget: getInspektorGadgetScenarios(), kubectl: getKubectlScenarios(), - aso: getASOScenarios() + aso: getASOScenarios(), + tcpDump: getTCPDumpScenarios() }; const testScenarios = Object.values(contentTestScenarios).flatMap(s => s); diff --git a/webview-ui/src/manualTest/tcpDumpTests.tsx b/webview-ui/src/manualTest/tcpDumpTests.tsx new file mode 100644 index 000000000..91bf327e2 --- /dev/null +++ b/webview-ui/src/manualTest/tcpDumpTests.tsx @@ -0,0 +1,156 @@ +import { MessageHandler, MessageSink } from "../../../src/webview-contract/messaging"; +import { CaptureName, CompletedCapture, InitialState, NodeName, ToVsCodeMsgDef, ToWebViewMsgDef } from "../../../src/webview-contract/webviewDefinitions/tcpDump"; +import { TcpDump } from "../TCPDump/TcpDump"; +import { stateUpdater } from "../TCPDump/state"; +import { Scenario } from "../utilities/manualTest"; + +type TestNodeState = { + isDebugPodRunning: boolean, + runningCapture: CaptureName | null, + completedCaptures: CompletedCapture[] +}; + +function getInitialNodeState(): TestNodeState { + return { + isDebugPodRunning: false, + runningCapture: null, + completedCaptures: [] + }; +} + +const goodNode = "good-node"; +const nodeWithRunningDebugPod = "node-with-running-debug-pod"; +const nodeThatFailsToCreateDebugPod = "node-that-fails-to-create-debug-pod"; +const nodeThatFailsToDeleteDebugPod = "node-that-fails-to-delete-debug-pod"; +const nodeThatFailsToStartCapture = "node-that-fails-to-start-capture"; +const nodeThatFailsToStopCapture = "node-that-fails-to-stop-capture"; +const nodeWhereDownloadsFail = "node-where-downloads-fail"; + +const randomFileSize = () => ~~(Math.random() * 5000); + +export function getTCPDumpScenarios() { + const clusterName = "test-cluster"; + let nodeStates: {[node: NodeName]: TestNodeState} = { + [goodNode]: getInitialNodeState(), + [nodeWithRunningDebugPod]: { + isDebugPodRunning: true, + runningCapture: null, + completedCaptures: Array.from({length: 8}, (_, i) => ({name: String(i + 1).padStart(4, '0'), sizeInKB: randomFileSize()})) + }, + [nodeThatFailsToCreateDebugPod]: getInitialNodeState(), + [nodeThatFailsToDeleteDebugPod]: getInitialNodeState(), + [nodeThatFailsToStartCapture]: getInitialNodeState(), + [nodeThatFailsToStopCapture]: getInitialNodeState(), + [nodeWhereDownloadsFail]: getInitialNodeState() + } + const initialState: InitialState = { + clusterName, + allNodes: Object.keys(nodeStates), + } + + function getMessageHandler(webview: MessageSink): MessageHandler { + return { + checkNodeState: args => handleCheckNodeState(args.node), + startDebugPod: args => handleStartDebugPod(args.node), + deleteDebugPod: args => handleDeleteDebugPod(args.node), + startCapture: args => handleStartCapture(args.node, args.capture), + stopCapture: args => handleStopCapture(args.node, args.capture), + downloadCaptureFile: args => handleDownloadCaptureFile(args.node, args.capture), + openFolder: args => handleOpenFolder(args) + } + + async function handleCheckNodeState(node: NodeName) { + await new Promise(resolve => setTimeout(resolve, 2000)); + webview.postCheckNodeStateResponse({ + succeeded: true, + errorMessage: null, + node, + ...nodeStates[node] + }); + } + + async function handleStartDebugPod(node: NodeName) { + await new Promise(resolve => setTimeout(resolve, 5000)); + const succeeded = node !== nodeThatFailsToCreateDebugPod; + if (succeeded) { + nodeStates[node] = { + isDebugPodRunning: succeeded, + runningCapture: null, + completedCaptures: [] + }; + } + + webview.postStartDebugPodResponse({ + node, + succeeded, + errorMessage: succeeded ? null : "Creating the debug pod didn't work. Sorry." + }); + } + + async function handleDeleteDebugPod(node: NodeName) { + await new Promise(resolve => setTimeout(resolve, 2000)); + const succeeded = node !== nodeThatFailsToDeleteDebugPod; + if (succeeded) { + nodeStates[node] = getInitialNodeState(); + } + + webview.postDeleteDebugPodResponse({ + node, + succeeded: true, + errorMessage: succeeded ? null : "Deleting the debug pod didn't work. Sorry." + }); + } + + async function handleStartCapture(node: NodeName, capture: CaptureName) { + await new Promise(resolve => setTimeout(resolve, 2000)); + const succeeded = node !== nodeThatFailsToStartCapture; + if (succeeded) { + nodeStates[node] = {...nodeStates[node], runningCapture: capture}; + } + + webview.postStartCaptureResponse({ + node, + succeeded, + errorMessage: succeeded ? null : "Starting the capture didn't work. Sorry." + }); + } + + async function handleStopCapture(node: NodeName, capture: CaptureName) { + await new Promise(resolve => setTimeout(resolve, 2000)); + const nodeState = nodeStates[node]; + + const succeeded = node !== nodeThatFailsToStartCapture; + let stoppedCapture = null; + if (succeeded) { + stoppedCapture = {name: capture, sizeInKB: randomFileSize()}; + nodeStates[node] = {...nodeState, runningCapture: null, completedCaptures: [...nodeState.completedCaptures, stoppedCapture]}; + } + + webview.postStopCaptureResponse({ + node, + succeeded, + errorMessage: succeeded ? null : "Stopping the capture didn't work. Sorry.", + capture: stoppedCapture + }); + } + + async function handleDownloadCaptureFile(node: NodeName, capture: CaptureName) { + await new Promise(resolve => setTimeout(resolve, 2000)); + webview.postDownloadCaptureFileResponse({ + node, + succeeded: true, + errorMessage: null, + captureName: capture, + localCapturePath: `/reasonably/long/path/to/eventually/get/to/${capture}.cap` + }); + } + + function handleOpenFolder(path: string) { + alert(`VS Code would launch an OS file system browser here:\n${path}`); + } + } + + return [ + Scenario.create("tcpDump", "", () => , getMessageHandler, stateUpdater.vscodeMessageHandler) + ]; +}