diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 79f621488..08830c366 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,7 +8,7 @@ jobs: JobBuild: name: release runs-on: ubuntu-latest - # Expose step outputs as job outputs + # Expose step outputs as job outputs outputs: currentversion: ${{ steps.package_version.outputs.current-version }} changelog_reader_changes: ${{ steps.changelog_reader.outputs.changes }} @@ -59,7 +59,19 @@ jobs: uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 with: egress-policy: audit - + # Checkout the code again for release + - name: Checkout + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + - name: Use Node.js + uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + with: + node-version: 20 + # Run install dependencies + - name: Install dependencies + run: npm run install:all + # Ensure project builds successfully before creating release + - name: Build + run: npm run webpack - name: Create a Release id: create_release uses: actions/create-release@0cb9c9b65d5d1901c1f53e5e66eaf4afd303e70e # v1.1.4 diff --git a/CHANGELOG.md b/CHANGELOG.md index fb2820a0f..0bbfc2cd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Change Log +## [1.4.3] + +* Retina integration with VsCode. +* In-house download replacement implementation. +* Show-properties page k8s version is now available with deprecated warning. +* Dependabot updates and bumps. + +Thank you so much @sprab for continued effort for Retina User-Sceanrios testing changes, thanks you so much to @hsubramanianaks for Retina help and U/X changes, thanks to, @sprab, @hsubramanianaks, & @peterbom for comments and testing. Thank you @rbtr and @vakalapa for async Retina Fixes and for `0.0.7` release. Thanks all for our other BAU contributors. + ## [1.4.2] * Show Properties page new feature for help information with k8s version deprecated available . diff --git a/docs/book/src/README.md b/docs/book/src/README.md index 32c4e1d8a..b52bc112a 100644 --- a/docs/book/src/README.md +++ b/docs/book/src/README.md @@ -22,6 +22,7 @@ Azure Kubernetes Service (AKS) Extension for Visual Studio Code helps enable AKS * [Collect TCP Dumps](./features/tcp-dumps.md) * [Compare AKS Cluster](./features/aks-compare-cluster.md) * [Run Image Cleaner Eraser Tool](./features/image-cleaner-eraser-tool.md) +* [Run Retina Capture](./features/retina-capture.md) ## Development and Release diff --git a/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md index 53340355e..98516fcdd 100644 --- a/docs/book/src/SUMMARY.md +++ b/docs/book/src/SUMMARY.md @@ -23,6 +23,7 @@ - [Collect TCP Dump](./features/tcp-dumps.md) - [Compare AKS Cluster](./features/aks-compare-cluster.md) - [Run Image Cleaner Eraser Tool](./features/image-cleaner-eraser-tool.md) + - [Run Retina Capture](./features/retina-capture.md) - [Release](./release.md) - [Releasing Information](./release/releasing.md) - [Contributing](./contributing.md) diff --git a/docs/book/src/features/features.md b/docs/book/src/features/features.md index 436dbe0c1..ec2db3753 100644 --- a/docs/book/src/features/features.md +++ b/docs/book/src/features/features.md @@ -30,4 +30,6 @@ Once you successfully log in with your Azure Account, you can view all AKS clust ![AKS Show Properties webview](../resources/show-properties-reconcile.png) -![Cloud Explorer Compare AKS Cluster](../resources/aks-compare-cluster-result.png) \ No newline at end of file +![Cloud Explorer Compare AKS Cluster](../resources/aks-compare-cluster-result.png) + +![Cloud Explorer Run Retina Capture on AKS Cluster](../resources/retina-success-run-download.png) diff --git a/docs/book/src/features/inspektor-gadget.md b/docs/book/src/features/inspektor-gadget.md index dd48a7895..b68816648 100644 --- a/docs/book/src/features/inspektor-gadget.md +++ b/docs/book/src/features/inspektor-gadget.md @@ -2,7 +2,7 @@ ### Deploy and Undeploy InspektorGadget -Right click on your AKS cluster and click on **Show Inspektor Gadget** to easily deploy gadget into your cluster. User can easily one-click deploy and undeploy gadget from this feature. +Right click on your AKS cluster and select **Troubleshoot Network Health** and then click on **Show Inspektor Gadget** to easily deploy gadget into your cluster. User can easily one-click deploy and undeploy gadget from this feature. ### Profile, Top, Trace and Snapshot Inspektor Gadget Commands diff --git a/docs/book/src/features/retina-capture.md b/docs/book/src/features/retina-capture.md new file mode 100644 index 000000000..3d55b9d5c --- /dev/null +++ b/docs/book/src/features/retina-capture.md @@ -0,0 +1,11 @@ +# Run Retina Distributed Capture from AKS Cluster Linux Nodes + +### Run Retina Capture + +Right click on your AKS cluster and select **Troubleshoot Network Health** and then click on **Run Retina Capture** to capture logs like iptables-rules, [ip-resrouces.txt and other key distributed captures form this azure networking tool](https://retina.sh/docs/captures/cli#file-and-directory-structure-inside-the-tarball) for any Linux node and download them to your local machine with ease. + +![Step 1: Menu](../resources/right-click-retina-capture.png) + +![Step 2: Select Nodes to Run Retina](../resources/retina-select-nodes.png) + +![Step 3: Retina Ran Successfully](../resources/retina-success-run-download.png) diff --git a/docs/book/src/features/tcp-dumps.md b/docs/book/src/features/tcp-dumps.md index 8c8e82fa8..36f3031a1 100644 --- a/docs/book/src/features/tcp-dumps.md +++ b/docs/book/src/features/tcp-dumps.md @@ -2,7 +2,7 @@ ### Collect TCP Dumps -Right click on your AKS cluster and select **Collect TCP Dumps** to capture TCP dumps for any Linux node and download them to your local machine with ease. +Right click on your AKS cluster and select **Troubleshoot Network Health** and then select **Collect TCP Dumps** to capture TCP dumps for any Linux node and download them to your local machine with ease. Added filters to the TCP Dump functionality, so that you can target traffic capture to specific network interfaces, ports or protocols, to or from specific pods, or craft custom [pcap filter strings](https://www.tcpdump.org/manpages/pcap-filter.7.html). diff --git a/docs/book/src/resources/retina-select-nodes.png b/docs/book/src/resources/retina-select-nodes.png new file mode 100644 index 000000000..104ebcdae Binary files /dev/null and b/docs/book/src/resources/retina-select-nodes.png differ diff --git a/docs/book/src/resources/retina-success-run-download.png b/docs/book/src/resources/retina-success-run-download.png new file mode 100644 index 000000000..4839e635a Binary files /dev/null and b/docs/book/src/resources/retina-success-run-download.png differ diff --git a/docs/book/src/resources/right-click-retina-capture.png b/docs/book/src/resources/right-click-retina-capture.png new file mode 100644 index 000000000..466ec6a11 Binary files /dev/null and b/docs/book/src/resources/right-click-retina-capture.png differ diff --git a/package-lock.json b/package-lock.json index a3f15e4cb..c05aac90c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vscode-aks-tools", - "version": "1.4.2", + "version": "1.4.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "vscode-aks-tools", - "version": "1.4.2", + "version": "1.4.3", "license": "MIT", "dependencies": { "@azure/arm-authorization": "^9.0.0", diff --git a/package.json b/package.json index fd2e52aa0..c4ccb15ea 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "vscode-aks-tools", "displayName": "Azure Kubernetes Service", "description": "Display Azure Kubernetes Services within VS Code", - "version": "1.4.2", + "version": "1.4.3", "aiKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255", "publisher": "ms-kubernetes-tools", "icon": "resources/aks-tools.png", @@ -144,6 +144,12 @@ "default": "v0.0.30", "title": "Draft repository release tag", "description": "Release tag for the stable Draft tool release." + }, + "aks.retinatool.releaseTag": { + "type": "string", + "default": "v0.0.7", + "title": "Retina repository release tag", + "description": "Release tag for the stable Retina tool release." } } }, @@ -259,6 +265,10 @@ { "command": "aks.compareCluster", "title": "Compare AKS Cluster" + }, + { + "command": "aks.aksRetinaCapture", + "title": "Run Retina Capture" } ], "menus": { @@ -331,19 +341,14 @@ "when": "view == kubernetes.cloudExplorer && viewItem =~ /aks\\.cluster/i || view == extension.vsKubernetesExplorer && viewItem =~ /vsKubernetes\\.\\w*cluster$/i", "group": "9@3" }, - { - "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" - }, { "command": "aks.compareCluster", "when": "view == kubernetes.cloudExplorer && viewItem =~ /aks\\.subscription/i" + }, + { + "submenu": "aks.networkTroubleshootingSubMenu", + "when": "view == kubernetes.cloudExplorer && viewItem =~ /aks\\.cluster/i", + "group": "9@3" } ], "aks.createClusterSubMenu": [ @@ -400,6 +405,20 @@ "group": "navigation" } ], + "aks.networkTroubleshootingSubMenu": [ + { + "command": "aks.aksTCPDump", + "group": "navigation" + }, + { + "command": "aks.aksInspektorGadgetShow", + "group": "navigation" + }, + { + "command": "aks.aksRetinaCapture", + "group": "navigation" + } + ], "aks.managedClusterOperationSubMenu": [ { "command": "aks.aksDeleteCluster", @@ -435,6 +454,10 @@ { "id": "aks.tcpDataCollectionSubMenu", "label": "Collect TCP Dump" + }, + { + "id": "aks.networkTroubleshootingSubMenu", + "label": "Troubleshoot Network Health" } ] }, diff --git a/src/commands/aksRetinaCapture/aksRetinaCapture.ts b/src/commands/aksRetinaCapture/aksRetinaCapture.ts new file mode 100644 index 000000000..04dff657c --- /dev/null +++ b/src/commands/aksRetinaCapture/aksRetinaCapture.ts @@ -0,0 +1,135 @@ +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, longRunning } from "../utils/host"; +import * as tmpfile from "../utils/tempfile"; +import path from "path"; +import { ensureDirectoryInPath } from "../utils/env"; +import { getRetinaBinaryPath } from "../utils/helper/retinaBinaryDownload"; +import { getVersion, invokeKubectlCommand } from "../utils/kubectl"; +import { RetinaCapturePanel, RetinaCaptureProvider } from "../../panels/RetinaCapturePanel"; +import { failed } from "../utils/errorable"; +import { getLinuxNodes } from "../../panels/utilities/KubectlNetworkHelper"; + +export async function aksRetinaCapture(_context: IActionContext, target: unknown): Promise { + 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 kubectlRetinaPath = await getRetinaBinaryPath(); + if (failed(kubectlRetinaPath)) { + vscode.window.showWarningMessage(`kubectl retina path was not found ${kubectlRetinaPath.error}`); + return; + } + + ensureDirectoryInPath(path.dirname(kubectlRetinaPath.result)); + + const extension = getExtension(); + if (failed(extension)) { + vscode.window.showErrorMessage(extension.error); + return; + } + + // Get all Linux Nodes For this Cluster + 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; + } + + // Pick a Node to Capture Traffic From + const nodeNamesSelected = await vscode.window.showQuickPick(linuxNodesList.result, { + canPickMany: true, + placeHolder: "Please select all the Nodes you want Retina to capture traffic from.", + title: "Select Nodes to Capture Traffic From", + }); + + if (!nodeNamesSelected) { + vscode.window.showErrorMessage('No nodes were selected to capture traffic.'); + return; + } + + const selectedNodes = nodeNamesSelected.map((item) => item).join(","); + + if (!selectedNodes) { + return; + } + + // Retina Run Capture + const capturename = `retina-capture-${clusterInfo.result.name.toLowerCase()}`; + const retinaCaptureResult = await longRunning(`Retina Distributed Capture running for cluster ${clusterInfo.result.name}.`, async () => { + return await invokeKubectlCommand( + kubectl, + kubeConfigFile.filePath, + `retina capture create --namespace default --name ${capturename} --host-path /mnt/capture --node-selectors "kubernetes.io/os=linux" --node-names "${selectedNodes}" --no-wait=false`, + ) + }); + + if (failed(retinaCaptureResult)) { + vscode.window.showErrorMessage(`Failed to capture the cluster: ${retinaCaptureResult.error}`); + return; + } + + if (retinaCaptureResult.result.stdout && retinaCaptureResult.result.code === 0) { + vscode.window.showInformationMessage(`Retina distributed capture is successfully completed for the cluster ${clusterInfo.result.name}`); + } + + const kubectlVersion = await getVersion(kubectl, kubeConfigFile.filePath); + if (failed(kubectlVersion)) { + vscode.window.showErrorMessage(kubectlVersion.error); + return; + } + + const foldername = `${capturename}_${(new Date().toJSON().replaceAll(":", ""))}`; + + // find if node explorer pod is already exists + let nodeExplorerPodExists = false; + const nodeExplorerPod = await invokeKubectlCommand( + kubectl, + kubeConfigFile.filePath, + `get pods -n default -l app=node-explorer`, + ); + + + if (nodeExplorerPod.succeeded && nodeExplorerPod.result.stdout && nodeExplorerPod.result.stdout.includes("node-explorer")) { + nodeExplorerPodExists = true; + } + + + const dataProvider = new RetinaCaptureProvider( + kubectl, + kubectlVersion.result, + kubeConfigFile.filePath, + clusterInfo.result.name, + retinaCaptureResult.result.stdout, + selectedNodes.split(","), + `${foldername}`, + nodeExplorerPodExists, + ); + + const panel = new RetinaCapturePanel(extension.result.extensionUri); + panel.show(dataProvider, kubeConfigFile); +} diff --git a/src/commands/aksTCPCollection/tcpDumpCollection.ts b/src/commands/aksTCPCollection/tcpDumpCollection.ts index 8668e9319..063ca05c7 100644 --- a/src/commands/aksTCPCollection/tcpDumpCollection.ts +++ b/src/commands/aksTCPCollection/tcpDumpCollection.ts @@ -3,10 +3,11 @@ 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 { failed } from "../utils/errorable"; import * as tmpfile from "../utils/tempfile"; import { TcpDumpDataProvider, TcpDumpPanel } from "../../panels/TcpDumpPanel"; -import { getVersion, invokeKubectlCommand } from "../utils/kubectl"; +import { getVersion } from "../utils/kubectl"; +import { getLinuxNodes } from "../../panels/utilities/KubectlNetworkHelper"; export async function aksTCPDump(_context: IActionContext, target: unknown) { const kubectl = await k8s.extension.kubectl.v1; @@ -64,12 +65,3 @@ export async function aksTCPDump(_context: IActionContext, target: unknown) { 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")); -} diff --git a/src/commands/periscope/models/DraftConfig.ts b/src/commands/periscope/models/DraftConfig.ts deleted file mode 100644 index 8775c5c6c..000000000 --- a/src/commands/periscope/models/DraftConfig.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface DraftConfig { - releaseTag: string; -} diff --git a/src/commands/periscope/models/RetinaDownloadConfig.ts b/src/commands/periscope/models/RetinaDownloadConfig.ts new file mode 100644 index 000000000..625debc35 --- /dev/null +++ b/src/commands/periscope/models/RetinaDownloadConfig.ts @@ -0,0 +1,3 @@ +export interface RetinaDownloadConfig { + releaseTag: string; +} diff --git a/src/commands/utils/config.ts b/src/commands/utils/config.ts index 0d5c03cdd..e629e06a2 100644 --- a/src/commands/utils/config.ts +++ b/src/commands/utils/config.ts @@ -3,7 +3,7 @@ import { combine, failed, Errorable } from "./errorable"; import { KubeloginConfig, KustomizeConfig } from "../periscope/models/config"; import * as semver from "semver"; import { CommandCategory, PresetCommand } from "../../webview-contract/webviewDefinitions/kubectl"; -import { DraftConfig } from "../periscope/models/DraftConfig"; +import { RetinaDownloadConfig } from "../periscope/models/RetinaDownloadConfig"; import { isObject } from "./runtimeTypes"; export function getKustomizeConfig(): Errorable { @@ -74,7 +74,7 @@ export function getKubectlGadgetConfig(): Errorable { return { succeeded: true, result: configresult }; } -export function getDraftConfig(): Errorable { +export function getDraftConfig(): Errorable { const draftConfig = vscode.workspace.getConfiguration("aks.drafttool"); const props = getConfigValue(draftConfig, "releaseTag"); @@ -92,6 +92,24 @@ export function getDraftConfig(): Errorable { return { succeeded: true, result: config }; } +export function getRetinaConfig(): Errorable { + const retinaconfig = vscode.workspace.getConfiguration("aks.retinatool"); + const props = getConfigValue(retinaconfig, "releaseTag"); + + if (failed(props)) { + return { + succeeded: false, + error: `Failed to read aks.retina configuration: ${props.error}`, + }; + } + + const config = { + releaseTag: props.result, + }; + + return { succeeded: true, result: config }; +} + function getConfigValue(config: vscode.WorkspaceConfiguration, key: string): Errorable { const value = config.get(key); if (value === undefined) { diff --git a/src/commands/utils/helper/retinaBinaryDownload.ts b/src/commands/utils/helper/retinaBinaryDownload.ts new file mode 100644 index 000000000..3b493a948 --- /dev/null +++ b/src/commands/utils/helper/retinaBinaryDownload.ts @@ -0,0 +1,86 @@ +import * as vscode from "vscode"; +import * as os from "os"; +import { getRetinaConfig } from "../config"; +import { Errorable, failed } from "../errorable"; +import { getToolBinaryPath } from "./binaryDownloadHelper"; + +async function getLatestRetinaReleaseTag() { + const retinaConfig = getRetinaConfig(); + if (failed(retinaConfig)) { + vscode.window.showErrorMessage(retinaConfig.error); + return undefined; + } + + return retinaConfig.result.releaseTag; +} + +export async function getRetinaBinaryPath(): Promise> { + const releaseTag = await getLatestRetinaReleaseTag(); + + if (!releaseTag) { + return { + succeeded: false, + error: `Failed to get latest release tag for downloading retina`, + }; + } + const archiveFilename = getArchiveFilename(releaseTag); + + const downloadUrl = `https://github.com/microsoft/retina/releases/download/${releaseTag}/${archiveFilename}`; + const pathToBinaryInArchive = getPathToBinaryInArchive(); + + // The plugin requires an '.exe' extension on Windows, but it doesn't have that in the archive + // so we can't simply extract it from the path within the archive. + const binaryFilename = getBinaryFileName(); + + return await getToolBinaryPath("kubectl-retina", releaseTag, binaryFilename, { + downloadUrl, + isCompressed: true, + pathToBinaryInArchive, + }); +} + +function getArchiveFilename(releaseTag: string) { + let architecture = os.arch(); + let operatingSystem = os.platform().toLocaleLowerCase(); + + if (architecture === "x64") { + architecture = "amd64"; + } + + if (operatingSystem === "win32") { + operatingSystem = "windows"; + // scaffolding but will find much better way to handle this + return `kubectl-retina-${operatingSystem}-${architecture}-${releaseTag}.zip`; + } + + return `kubectl-retina-${operatingSystem}-${architecture}-${releaseTag}.tar.gz`; +} + + +function getPathToBinaryInArchive() { + let architecture = os.arch(); + let operatingSystem = os.platform().toLocaleLowerCase(); + + if (architecture === "x64") { + architecture = "amd64"; + } + + let extension = ""; + if (operatingSystem === "win32") { + operatingSystem = "windows"; + extension = ".exe"; + } + + return `kubectl-retina-${operatingSystem}-${architecture}${extension}`; +} + +function getBinaryFileName() { + const operatingSystem = os.platform().toLocaleLowerCase(); + + let extension = ""; + if (operatingSystem === "win32") { + extension = ".exe"; + } + + return `kubectl-retina${extension}`; +} diff --git a/src/extension.ts b/src/extension.ts index b5db10087..827ceaac7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -45,6 +45,7 @@ import { aksTCPDump } from "./commands/aksTCPCollection/tcpDumpCollection"; import aksCompareCluster from "./commands/aksCompareCluster/aksCompareCluster"; import refreshSubscription from "./commands/refreshSubscriptions"; import aksEraserTool from "./commands/aksEraserTool/erasertool"; +import { aksRetinaCapture } from "./commands/aksRetinaCapture/aksRetinaCapture"; export async function activate(context: vscode.ExtensionContext) { const cloudExplorer = await k8s.extension.cloudExplorer.v1; @@ -95,6 +96,7 @@ export async function activate(context: vscode.ExtensionContext) { registerCommandWithTelemetry("aks.compareCluster", aksCompareCluster); registerCommandWithTelemetry("aks.refreshSubscription", refreshSubscription); registerCommandWithTelemetry("aks.eraserTool", aksEraserTool); + registerCommandWithTelemetry("aks.aksRetinaCapture", aksRetinaCapture); await registerAzureServiceNodes(context); diff --git a/src/panels/RetinaCapturePanel.ts b/src/panels/RetinaCapturePanel.ts new file mode 100644 index 000000000..00a41bb85 --- /dev/null +++ b/src/panels/RetinaCapturePanel.ts @@ -0,0 +1,187 @@ +import open from 'open'; +import * as vscode from "vscode"; +import { Uri, window } from "vscode"; +import * as k8s from "vscode-kubernetes-tools-api"; +import { failed } from "../commands/utils/errorable"; +import { longRunning } from "../commands/utils/host"; +import { KubectlVersion, invokeKubectlCommand } from "../commands/utils/kubectl"; +import { withOptionalTempFile } from "../commands/utils/tempfile"; +import { MessageHandler } from "../webview-contract/messaging"; +import { InitialState, ToVsCodeMsgDef } from "../webview-contract/webviewDefinitions/retinaCapture"; +import { TelemetryDefinition } from "../webview-contract/webviewTypes"; +import { BasePanel, PanelDataProvider } from "./BasePanel"; +import { getLocalKubectlCpPath } from './utilities/KubectlNetworkHelper'; +import * as semver from 'semver'; + + +export class RetinaCapturePanel extends BasePanel<"retinaCapture"> { + constructor(extensionUri: Uri) { + super(extensionUri, "retinaCapture", { + startCaptureResponse: "", + getAllNodesResponse: [], + }); + } +} + +export class RetinaCaptureProvider implements PanelDataProvider<"retinaCapture"> { + constructor( + readonly kubectl: k8s.APIAvailable, + readonly kubectlVersion: KubectlVersion, + readonly kubeConfigFilePath: string, + readonly clusterName: string, + readonly retinaOutput: string, + readonly allNodeOutput: string[], + readonly captureFolderName: string, + readonly isNodeExplorerPodExists : boolean + ) { } + + getTitle(): string { + return `Retina Distributed Capture on ${this.clusterName}`; + } + + getTelemetryDefinition(): TelemetryDefinition<"retinaCapture"> { + return { + handleCaptureFileDownload: true, + deleteRetinaNodeExplorer: true, + } + } + + getInitialState(): InitialState { + return { + clusterName: this.clusterName, + retinaOutput: [this.retinaOutput], + allNodes: this.allNodeOutput, + selectedNode: "", + captureFolderName: this.captureFolderName, + isNodeExplorerPodExists: this.isNodeExplorerPodExists + }; + } + + getMessageHandler(): MessageHandler { + return { + handleCaptureFileDownload: (node: string) => this.handleCaptureFileDownload(node), + deleteRetinaNodeExplorer: (node: string) => { this. handleDeleteRetinaNodeExplorer(node) } + }; + } + + private async handleDeleteRetinaNodeExplorer(node: string) { + // node is a comma separated string of node names + // ex: "aks-nodepool1-12345678-vmss000000,aks-nodepool1-12345678-vmss000001" + const nodes = node.split(","); + for (const node of nodes) { + await this.deleteNodeExplorerUsingKubectl(node); + } + } + + private async deleteNodeExplorerUsingKubectl(node: string) { + const deleteResult = await longRunning(`Deleting pod node-explorer-${node}.`, async () => { + const command = `delete pod node-explorer-${node}`; + return await invokeKubectlCommand(this.kubectl, this.kubeConfigFilePath, command); + }); + + if (failed(deleteResult)) { + vscode.window.showErrorMessage(`Failed to delete Pod: ${deleteResult.error}`); + return; + } + } + + private async handleCaptureFileDownload(node: string) { + const localCaptureUri = await window.showSaveDialog({ + defaultUri: Uri.file(this.captureFolderName), + saveLabel: "Download", + title: "Download Retina File", + }); + + if (!localCaptureUri) { + return; + } + + const localCpPath = getLocalKubectlCpPath(localCaptureUri); + + const nodes = node.split(","); + for (const node of nodes) { + await this.copyRetinaCaptureData(node, localCpPath); + } + +} + + async copyRetinaCaptureData(node: string, localCpPath: string) { + const createPodYaml = ` +apiVersion: v1 +kind: Pod +metadata: + name: node-explorer-${node} + labels: + app: node-explorer +spec: + nodeName: ${node} + tolerations: + - key: CriticalAddonsOnly + operator: Exists + - effect: NoExecute + operator: Exists + - effect: NoSchedule + operator: Exists + volumes: + - name: mnt-captures + hostPath: + path: /mnt/capture + containers: + - name: node-explorer + image: alpine + command: ["sleep", "9999999999"] + volumeMounts: + - name: mnt-captures + mountPath: /mnt/capture +` ; + + const applyResult = await longRunning(`Deploying pod to capture ${node} retina data.`, async () => { + return await withOptionalTempFile(createPodYaml, "YAML", async (podSpecFile) => { + const command = `apply -f ${podSpecFile}`; + return await invokeKubectlCommand(this.kubectl, this.kubeConfigFilePath, command); + }); + }); + + if (failed(applyResult)) { + vscode.window.showErrorMessage(`Failed to apply Pod: ${applyResult.error}`); + return; + } + const waitResult = await longRunning(`waiting for pod to get ready node-explorer-${node}.`, async () => { + const command = `wait pod -n default --for=condition=ready --timeout=300s node-explorer-${node}`; + return await invokeKubectlCommand(this.kubectl, this.kubeConfigFilePath, command); + }); + + if (failed(waitResult)) { + vscode.window.showErrorMessage(`Failed to wait for Pod to be ready: ${waitResult.error}`); + return; + } + + + /* kubectl cp functionality is used to copy the data from the pod to the local host. + `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 captureHostFolderName = `${localCpPath}-${node}`; + const nodeExplorerResult = await longRunning(`Copy captured data to local host location ${captureHostFolderName}.`, async () => { + const cpcommand = `cp node-explorer-${node}:mnt/capture ${captureHostFolderName} ${cpEOFAvoidanceFlag}`; + return await invokeKubectlCommand(this.kubectl, this.kubeConfigFilePath, cpcommand); + }); + + if (failed(nodeExplorerResult)) { + vscode.window.showErrorMessage(`Failed to apply copy command: ${nodeExplorerResult.error}`); + return; + } + + const goToFolder = "Go to Folder"; + vscode.window.showInformationMessage(`Successfully copied the Retina Capture data to ${captureHostFolderName}`, goToFolder) + .then(selection => { + if (selection === goToFolder) { + open(captureHostFolderName); + } + }); + } +} diff --git a/src/panels/utilities/KubectlNetworkHelper.ts b/src/panels/utilities/KubectlNetworkHelper.ts new file mode 100644 index 000000000..c04e958e6 --- /dev/null +++ b/src/panels/utilities/KubectlNetworkHelper.ts @@ -0,0 +1,36 @@ +import { platform } from "os"; +import { Uri, workspace } from "vscode"; +import { relative } from "path"; +import { invokeKubectlCommand } from "../../commands/utils/kubectl"; +import { Errorable, map as errmap } from "../../commands/utils/errorable"; +import * as k8s from "vscode-kubernetes-tools-api"; + + +export 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); +} + +export 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")); +} diff --git a/src/webview-contract/webviewDefinitions/retinaCapture.ts b/src/webview-contract/webviewDefinitions/retinaCapture.ts new file mode 100644 index 000000000..e1769fdf2 --- /dev/null +++ b/src/webview-contract/webviewDefinitions/retinaCapture.ts @@ -0,0 +1,19 @@ +import { WebviewDefinition } from "../webviewTypes"; + +export interface InitialState { + selectedNode: string; + clusterName: string; + retinaOutput: string[]; + allNodes: string[]; + captureFolderName: string; + isNodeExplorerPodExists: boolean; +} + +export type ToVsCodeMsgDef = { + deleteRetinaNodeExplorer: string; + handleCaptureFileDownload: string; +}; + +export type ToWebViewMsgDef = Record; + +export type RetinaCaptureDefinition = WebviewDefinition; diff --git a/src/webview-contract/webviewTypes.ts b/src/webview-contract/webviewTypes.ts index cd70f4c45..fca0ccf1f 100644 --- a/src/webview-contract/webviewTypes.ts +++ b/src/webview-contract/webviewTypes.ts @@ -8,6 +8,7 @@ import { PeriscopeDefinition } from "./webviewDefinitions/periscope"; import { TestStyleViewerDefinition } from "./webviewDefinitions/testStyleViewer"; import { ASODefinition } from "./webviewDefinitions/azureServiceOperator"; import { TCPDumpDefinition } from "./webviewDefinitions/tcpDump"; +import { RetinaCaptureDefinition } from "./webviewDefinitions/retinaCapture"; /** * Groups all the related types for a single webview. @@ -36,6 +37,7 @@ type AllWebviewDefinitions = { kubectl: KubectlDefinition; aso: ASODefinition; tcpDump: TCPDumpDefinition; + retinaCapture: RetinaCaptureDefinition; }; type ContentIdLookup = { diff --git a/webview-ui/src/RetinaCapture/DeleteNodeExplorerDialog.tsx b/webview-ui/src/RetinaCapture/DeleteNodeExplorerDialog.tsx new file mode 100644 index 000000000..49516331d --- /dev/null +++ b/webview-ui/src/RetinaCapture/DeleteNodeExplorerDialog.tsx @@ -0,0 +1,38 @@ +import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"; +import { Dialog } from "../components/Dialog"; +import styles from "./RetinaCapture.module.css"; + +export interface DeleteNodeExplorerDialogProps { + isShown: boolean; + nodes: string[]; + onCancel: () => void; + onAccept: (nodeName: string) => void; +} + +export function DeleteNodeExplorerDialog(props: DeleteNodeExplorerDialogProps) { + + function handleYes() { + props.onAccept(props.nodes.join(",")); + } + + function handleNo() { + props.onCancel(); + } + + return ( + props.onCancel()}> +

Delete Node Explorer

+ +
+
+ Are you sure you want to delete the Node Explorer? Deleting the Node Explorer will introduce delay for kubectl copy next time. +
+ +
+ Yes + No +
+
+
+ ); +} diff --git a/webview-ui/src/RetinaCapture/RetinaCapture.module.css b/webview-ui/src/RetinaCapture/RetinaCapture.module.css new file mode 100644 index 000000000..d410187c0 --- /dev/null +++ b/webview-ui/src/RetinaCapture/RetinaCapture.module.css @@ -0,0 +1,20 @@ +.content { + display: grid; + grid-template-columns: 6rem 50rem; + grid-gap: 1rem; + align-items: center; +} + +.inputContainer { + display: grid; + grid-template-columns: 8rem 20rem 8rem; + grid-gap: 1rem; +} + +.buttonContainer { + margin-top: 1rem; + display: flex; + flex-direction: row; + column-gap: 0.5rem; +} + diff --git a/webview-ui/src/RetinaCapture/RetinaCapture.tsx b/webview-ui/src/RetinaCapture/RetinaCapture.tsx new file mode 100644 index 000000000..8a30c5018 --- /dev/null +++ b/webview-ui/src/RetinaCapture/RetinaCapture.tsx @@ -0,0 +1,104 @@ +import { faInfoCircle, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { VSCodeButton, VSCodeCheckbox, VSCodeDivider } from "@vscode/webview-ui-toolkit/react"; +import { FormEvent, useState } from "react"; +import { InitialState } from "../../../src/webview-contract/webviewDefinitions/retinaCapture"; +import { useStateManagement } from "../utilities/state"; +import { DeleteNodeExplorerDialog } from "./DeleteNodeExplorerDialog"; +import styles from "./RetinaCapture.module.css"; +import { stateUpdater, vscode } from "./state"; + + +type ChangeEvent = Event | FormEvent; + +export function RetinaCapture(initialState: InitialState) { + const { state } = useStateManagement(stateUpdater, initialState, vscode); //eventHandlers + const [selectedNode, setSelectedNode] = useState>([]); + const [showDeleteNodeExplorerDialog, setShowDeleteNodeExplorerDialog] = useState(false); + + function handleCaptureFileDownload() { + const result = selectedNode.join(','); + vscode.postHandleCaptureFileDownload(result); + } + + function onSelectNode(e: ChangeEvent, node: string) { + if ((e.target as HTMLInputElement).checked) { + setSelectedNode([...selectedNode, node]); + } else { + setSelectedNode(selectedNode.filter(n => n !== node)); + } + } + + function isNodeSelected(node: string) { + return selectedNode.includes(node) && state.allNodes.includes(node); + } + + function handleDeleteExplorerPod() { + // show delete node explorer dialog + setShowDeleteNodeExplorerDialog(true); + } + + return ( + <> +
+

Retina Distributed Capture for {state.clusterName}

+
+ + +
+ Retina capture command allows the user to capture network traffic and metadata for the capture target, and then send the capture file to the location by Output Configuration. More info: Retina Capture Command +
+ +

Retina Output

+
+ {state.retinaOutput} +
+ + +

Retina Distributed Capture is Successfully Completed for this Cluster

+ + +
+
+ {state.allNodes.map((node) => ( +
+ onSelectNode(e, node)} + checked={isNodeSelected(node)}> + {node} + +
+ ))} +
+ handleCaptureFileDownload()}> + Download Retina Logs to Host Machine. + + {state.isNodeExplorerPodExists && ( + handleDeleteExplorerPod()}> + + + + Delete Node Explorer Pod + + )} + +
+ +
+
+ + {showDeleteNodeExplorerDialog && ( + setShowDeleteNodeExplorerDialog(false)} + onAccept={(nodeName) => { + console.log(nodeName); + vscode.postDeleteRetinaNodeExplorer(nodeName); + setShowDeleteNodeExplorerDialog(false); + }} + /> + )} + + ); +} diff --git a/webview-ui/src/RetinaCapture/state.ts b/webview-ui/src/RetinaCapture/state.ts new file mode 100644 index 000000000..e30255af5 --- /dev/null +++ b/webview-ui/src/RetinaCapture/state.ts @@ -0,0 +1,20 @@ +import { InitialState } from "../../../src/webview-contract/webviewDefinitions/retinaCapture"; +import { WebviewStateUpdater } from "../utilities/state"; +import { getWebviewMessageContext } from "../utilities/vscode"; + +export type EventDef = Record; + +export type RetinaState = InitialState; + +export const stateUpdater: WebviewStateUpdater<"retinaCapture", EventDef, RetinaState> = { + createState: (initialState) => ({ + ...initialState + }), + vscodeMessageHandler: {}, + eventHandler: {}, +}; + +export const vscode = getWebviewMessageContext<"retinaCapture">({ + deleteRetinaNodeExplorer: undefined, + handleCaptureFileDownload: undefined, +}); diff --git a/webview-ui/src/main.tsx b/webview-ui/src/main.tsx index fc3c10c76..01ab49a52 100644 --- a/webview-ui/src/main.tsx +++ b/webview-ui/src/main.tsx @@ -12,6 +12,7 @@ import { Kubectl } from "./Kubectl/Kubectl"; import { AzureServiceOperator } from "./AzureServiceOperator/AzureServiceOperator"; import { ClusterProperties } from "./ClusterProperties/ClusterProperties"; import { TcpDump } from "./TCPDump/TcpDump"; +import { RetinaCapture } from "./RetinaCapture/RetinaCapture"; // There are two modes of launching this application: // 1. Via the VS Code extension inside a Webview. @@ -50,6 +51,7 @@ function getVsCodeContent(): JSX.Element { kubectl: () => , aso: () => , tcpDump: () => , + retinaCapture: () => , }; return rendererLookup[vscodeContentId](); diff --git a/webview-ui/src/manualTest/main.tsx b/webview-ui/src/manualTest/main.tsx index 7df7629e1..0bcbd63a8 100644 --- a/webview-ui/src/manualTest/main.tsx +++ b/webview-ui/src/manualTest/main.tsx @@ -14,6 +14,7 @@ import { Scenario } from "../utilities/manualTest"; import { getASOScenarios } from "./asoTests"; import { getClusterPropertiesScenarios } from "./clusterPropertiesTests"; import { getTCPDumpScenarios } from "./tcpDumpTests"; +import { getRetinaCaptureScenarios } from "./retinaCaptureTests"; // There are two modes of launching this application: // 1. Via the VS Code extension inside a Webview. @@ -38,6 +39,7 @@ const contentTestScenarios: Record = { kubectl: getKubectlScenarios(), aso: getASOScenarios(), tcpDump: getTCPDumpScenarios(), + retinaCapture: getRetinaCaptureScenarios(), }; const testScenarios = Object.values(contentTestScenarios).flatMap((s) => s); diff --git a/webview-ui/src/manualTest/retinaCaptureTests.tsx b/webview-ui/src/manualTest/retinaCaptureTests.tsx new file mode 100644 index 000000000..fff800bf9 --- /dev/null +++ b/webview-ui/src/manualTest/retinaCaptureTests.tsx @@ -0,0 +1,39 @@ +import { InitialState } from "../../../src/webview-contract/webviewDefinitions/retinaCapture"; +import { RetinaCapture } from "../RetinaCapture/RetinaCapture"; +import { stateUpdater } from "../TestStyleViewer/state"; +import { Scenario } from "../utilities/manualTest"; + +export function getRetinaCaptureScenarios() { + + const initialState: InitialState = { + selectedNode: "node-1", + clusterName: "test-cluster", + retinaOutput: ["Microsoft open sources Retina: A cloud-native container networking observability platform. The Microsoft Azure Container Networking team is excited to announce Retina, a cloud - native container networking observability platform that enables Kubernetes users, admins, and developers to visualize, observe, debug, and analyze Kubernetes’ workload traffic irrespective of Container Network Interface(CNI), operating system(OS), and cloud.We are excited to release Retina as an open - source repository that helps with DevOps and SecOps related networking cases for your Kubernetes clusters and we invite the open- source community to innovate along with us."], + allNodes: ["aks-nodepool2-30344018-vmss000000", "aks-nodepool2-30344018-vmss000001", "aks-nodepool2-30344018-vmss000003"], + captureFolderName: "test-capture", + isNodeExplorerPodExists: true, + }; + + function getMessageHandler() { + return { + handleCaptureFileDownload: (node: string) => { + console.log(`Running retina capture on node ${node}`); + }, + deleteRetinaNodeExplorer: (result: string) => { + console.log(`Retina capture result: ${result}`); + }, + }; + + } + + return [ + Scenario.create( + "retinaCapture", + "", + () => , + getMessageHandler, + stateUpdater.vscodeMessageHandler, + ), + ]; + +} \ No newline at end of file