From 425afb11dafb3c34d1284eeb61ee6e5fd5c832f8 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Thu, 5 Sep 2024 10:29:05 +0200 Subject: [PATCH] send telemetry related to krunkit (#8680) * feat: send telemetry related to krunkit Signed-off-by: Philippe Martin * test: add unit tests for sendTelemetryRecords Signed-off-by: Philippe Martin * feat: send telemetry when stop machine Signed-off-by: Philippe Martin * fix: send provider code instead of label Signed-off-by: Philippe Martin * chore: keep qemu-helper for future use on Linux Signed-off-by: Philippe Martin --------- Signed-off-by: Philippe Martin --- extensions/podman/src/extension.spec.ts | 136 +++++++++++++++++-- extensions/podman/src/extension.ts | 64 ++++++--- extensions/podman/src/krunkit-helper.spec.ts | 77 +++++++++++ extensions/podman/src/krunkit-helper.ts | 39 ++++++ 4 files changed, 289 insertions(+), 27 deletions(-) create mode 100644 extensions/podman/src/krunkit-helper.spec.ts create mode 100644 extensions/podman/src/krunkit-helper.ts diff --git a/extensions/podman/src/extension.spec.ts b/extensions/podman/src/extension.spec.ts index a78f6ca3627..fb47daf3553 100644 --- a/extensions/podman/src/extension.spec.ts +++ b/extensions/podman/src/extension.spec.ts @@ -104,6 +104,11 @@ const telemetryLogger: extensionApi.TelemetryLogger = { logError: vi.fn(), } as unknown as extensionApi.TelemetryLogger; +const mocks = vi.hoisted(() => ({ + getPodmanLocationMacMock: vi.fn(), + getKrunkitVersionMock: vi.fn(), +})); + // mock ps-list vi.mock('ps-list', async () => { return { @@ -218,13 +223,11 @@ vi.mock('node:os', async () => { }; }); -vi.mock('./qemu-helper', async () => { +vi.mock('./krunkit-helper', async () => { return { - QemuHelper: vi.fn().mockImplementation(() => { + KrunkitHelper: vi.fn().mockImplementation(() => { return { - getQemuVersion: vi.fn().mockImplementation(() => { - return Promise.resolve('1.2.3'); - }), + getKrunkitVersion: mocks.getKrunkitVersionMock, }; }), }; @@ -233,9 +236,7 @@ vi.mock('./podman-binary-location-helper', async () => { return { PodmanBinaryLocationHelper: vi.fn().mockImplementation(() => { return { - getPodmanLocationMac: vi.fn().mockImplementation(() => { - return Promise.resolve({ source: 'unknown' }); - }), + getPodmanLocationMac: mocks.getPodmanLocationMacMock, }; }), }; @@ -2096,3 +2097,122 @@ test('isLibkrunSupported should return false with previous 5.1.2 version', async const enabled = extension.isLibkrunSupported('5.1.2'); expect(enabled).toBeFalsy(); }); + +test('sendTelemetryRecords with krunkit found', async () => { + vi.spyOn(podmanCli, 'getPodmanInstallation').mockResolvedValue({ + version: '5.1.2', + }); + mocks.getPodmanLocationMacMock.mockResolvedValue({ foundPath: '/opt/podman/bin/podman', source: 'installer' }); + mocks.getKrunkitVersionMock.mockResolvedValue('1.2.3'); + + extension.sendTelemetryRecords( + 'evt', + { + provider: 'libkrun', + } as Record, + false, + ); + await new Promise(resolve => setTimeout(resolve, 100)); + expect(telemetryLogger.logUsage).toHaveBeenCalledWith( + 'evt', + expect.objectContaining({ + krunkitPath: '/opt/podman/bin', + krunkitVersion: '1.2.3', + podmanCliFoundPath: '/opt/podman/bin/podman', + podmanCliSource: 'installer', + podmanCliVersion: '5.1.2', + provider: 'libkrun', + }), + ); +}); + +test('sendTelemetryRecords with krunkit not found', async () => { + vi.spyOn(podmanCli, 'getPodmanInstallation').mockResolvedValue({ + version: '5.1.2', + }); + mocks.getPodmanLocationMacMock.mockResolvedValue({ foundPath: '/opt/podman/bin/podman', source: 'installer' }); + mocks.getKrunkitVersionMock.mockRejectedValue('command not found'); + + extension.sendTelemetryRecords( + 'evt', + { + provider: 'libkrun', + } as Record, + false, + ); + await new Promise(resolve => setTimeout(resolve, 100)); + expect(telemetryLogger.logUsage).toHaveBeenCalledWith( + 'evt', + expect.objectContaining({ + errorKrunkitVersion: 'command not found', + podmanCliFoundPath: '/opt/podman/bin/podman', + podmanCliSource: 'installer', + podmanCliVersion: '5.1.2', + provider: 'libkrun', + }), + ); +}); + +test('if a machine stopped is successfully reporting telemetry', async () => { + const spyExecPromise = vi + .spyOn(extensionApi.process, 'exec') + .mockImplementation(() => Promise.resolve({} as extensionApi.RunResult)); + vi.spyOn(podmanCli, 'getPodmanInstallation').mockResolvedValue({ + version: '5.1.2', + }); + mocks.getPodmanLocationMacMock.mockResolvedValue({ foundPath: '/opt/podman/bin/podman', source: 'installer' }); + mocks.getKrunkitVersionMock.mockResolvedValue('1.2.3'); + await extension.stopMachine(provider, machineInfo); + + // wait a call on telemetryLogger.logUsage + while ((telemetryLogger.logUsage as Mock).mock.calls.length === 0) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + + expect(telemetryLogger.logUsage).toBeCalledWith( + 'podman.machine.stop', + expect.objectContaining({ + krunkitPath: '/opt/podman/bin', + krunkitVersion: '1.2.3', + podmanCliFoundPath: '/opt/podman/bin/podman', + podmanCliSource: 'installer', + podmanCliVersion: '5.1.2', + provider: 'libkrun', + }), + ); + expect(spyExecPromise).toBeCalledWith(podmanCli.getPodmanCli(), ['machine', 'stop', 'name'], expect.anything()); +}); + +test('if a machine stopped is successfully reporting an error in telemetry', async () => { + const customError = new Error('Error while starting podman'); + + const spyExecPromise = vi.spyOn(extensionApi.process, 'exec').mockImplementation(() => { + throw customError; + }); + vi.spyOn(podmanCli, 'getPodmanInstallation').mockResolvedValue({ + version: '5.1.2', + }); + mocks.getPodmanLocationMacMock.mockResolvedValue({ foundPath: '/opt/podman/bin/podman', source: 'installer' }); + mocks.getKrunkitVersionMock.mockResolvedValue('1.2.3'); + await expect(extension.stopMachine(provider, machineInfo)).rejects.toThrow(customError.message); + + // wait a call on telemetryLogger.logUsage + while ((telemetryLogger.logUsage as Mock).mock.calls.length === 0) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + + expect(telemetryLogger.logUsage).toBeCalledWith( + 'podman.machine.stop', + expect.objectContaining({ + krunkitPath: '/opt/podman/bin', + krunkitVersion: '1.2.3', + podmanCliFoundPath: '/opt/podman/bin/podman', + podmanCliSource: 'installer', + podmanCliVersion: '5.1.2', + error: customError, + provider: 'libkrun', + }), + ); + + expect(spyExecPromise).toBeCalledWith(podmanCli.getPodmanCli(), ['machine', 'stop', 'name'], expect.anything()); +}); diff --git a/extensions/podman/src/extension.ts b/extensions/podman/src/extension.ts index e9fe4c955ce..f821f7d9170 100644 --- a/extensions/podman/src/extension.ts +++ b/extensions/podman/src/extension.ts @@ -28,6 +28,7 @@ import { compareVersions } from 'compare-versions'; import { getSocketCompatibility } from './compatibility-mode'; import { getDetectionChecks } from './detection-checks'; +import { KrunkitHelper } from './krunkit-helper'; import { PodmanBinaryLocationHelper } from './podman-binary-location-helper'; import { PodmanCleanupMacOS } from './podman-cleanup-macos'; import { PodmanCleanupWindows } from './podman-cleanup-windows'; @@ -37,7 +38,6 @@ import { PodmanConfiguration } from './podman-configuration'; import { PodmanInfoHelper } from './podman-info-helper'; import { PodmanInstall } from './podman-install'; import { PodmanRemoteConnections } from './podman-remote-connections'; -import { QemuHelper } from './qemu-helper'; import { RegistrySetup } from './registry-setup'; import { appConfigDir, @@ -89,7 +89,7 @@ const configurationCompatibilityMode = 'setting.dockerCompatibility'; let telemetryLogger: extensionApi.TelemetryLogger | undefined; const wslHelper = new WslHelper(); -const qemuHelper = new QemuHelper(); +const krunkitHelper = new KrunkitHelper(); const podmanBinaryHelper = new PodmanBinaryLocationHelper(); const podmanInfoHelper = new PodmanInfoHelper(); @@ -746,10 +746,7 @@ export async function registerProviderFor( await startMachine(provider, podmanConfiguration, machineInfo, context, logger, undefined, false); }, stop: async (context, logger): Promise => { - await execPodman(['machine', 'stop', machineInfo.name], machineInfo.vmType, { - logger: new LoggerDelegator(context, logger), - }); - provider.updateStatus('stopped'); + await stopMachine(provider, machineInfo, context, logger); }, delete: async (logger): Promise => { await execPodman(['machine', 'rm', '-f', machineInfo.name], machineInfo.vmType, { @@ -871,6 +868,7 @@ export async function startMachine( autoStart?: boolean, ): Promise { const telemetryRecords: Record = {}; + telemetryRecords.provider = machineInfo.vmType; const startTime = performance.now(); await checkRosettaMacArm(podmanConfiguration); @@ -898,6 +896,31 @@ export async function startMachine( } } +export async function stopMachine( + provider: extensionApi.Provider, + machineInfo: MachineInfo, + context?: extensionApi.LifecycleContext, + logger?: extensionApi.Logger, +): Promise { + const startTime = performance.now(); + const telemetryRecords: Record = {}; + telemetryRecords.provider = machineInfo.vmType; + try { + await execPodman(['machine', 'stop', machineInfo.name], machineInfo.vmType, { + logger: new LoggerDelegator(context, logger), + }); + provider.updateStatus('stopped'); + } catch (err: unknown) { + telemetryRecords.error = err; + throw err; + } finally { + // send telemetry event + const endTime = performance.now(); + telemetryRecords.duration = endTime - startTime; + sendTelemetryRecords('podman.machine.stop', telemetryRecords, false); + } +} + async function doHandleError( provider: extensionApi.Provider, machineInfo: MachineInfo, @@ -1764,7 +1787,7 @@ export function isLibkrunSupported(podmanVersion: string): boolean { return isMac() && compareVersions(podmanVersion, PODMAN_MINIMUM_VERSION_FOR_LIBKRUN_SUPPORT) >= 0; } -function sendTelemetryRecords( +export function sendTelemetryRecords( eventName: string, telemetryRecords: Record, includeMachineStats: boolean, @@ -1784,16 +1807,16 @@ function sendTelemetryRecords( telemetryRecords.hostCpuModel = hostCpus[0].model; // on macOS, try to see if podman is coming from brew or from the installer - // and display version of qemu + // and display version of krunkit if (extensionApi.env.isMac) { - let qemuPath: string | undefined; + let krunkitPath: string | undefined; try { const podmanBinaryResult = await podmanBinaryHelper.getPodmanLocationMac(); telemetryRecords.podmanCliSource = podmanBinaryResult.source; if (podmanBinaryResult.source === 'installer') { - qemuPath = '/opt/podman/qemu/bin'; + krunkitPath = '/opt/podman/bin'; } telemetryRecords.podmanCliFoundPath = podmanBinaryResult.foundPath; if (podmanBinaryResult.error) { @@ -1804,16 +1827,18 @@ function sendTelemetryRecords( console.trace('unable to check from which path podman is coming', error); } - // add qemu version - try { - const qemuVersion = await qemuHelper.getQemuVersion(qemuPath); - if (qemuPath) { - telemetryRecords.qemuPath = qemuPath; + if (telemetryRecords.provider === 'libkrun') { + // add krunkit version + try { + const krunkitVersion = await krunkitHelper.getKrunkitVersion(krunkitPath); + if (krunkitPath) { + telemetryRecords.krunkitPath = krunkitPath; + } + telemetryRecords.krunkitVersion = krunkitVersion; + } catch (error) { + console.trace('unable to check krunkit version', error); + telemetryRecords.errorKrunkitVersion = error; } - telemetryRecords.qemuVersion = qemuVersion; - } catch (error) { - console.trace('unable to check qemu version', error); - telemetryRecords.errorQemuVersion = error; } } else if (extensionApi.env.isWindows) { // try to get wsl version @@ -1856,6 +1881,7 @@ export async function createMachine( let provider: string | undefined; if (params['podman.factory.machine.provider']) { provider = getProviderByLabel(params['podman.factory.machine.provider']); + telemetryRecords.provider = provider; } // cpus diff --git a/extensions/podman/src/krunkit-helper.spec.ts b/extensions/podman/src/krunkit-helper.spec.ts new file mode 100644 index 00000000000..f14f95ba0d3 --- /dev/null +++ b/extensions/podman/src/krunkit-helper.spec.ts @@ -0,0 +1,77 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import * as extensionApi from '@podman-desktop/api'; +import type { Mock } from 'vitest'; +import { beforeEach, expect, test, vi } from 'vitest'; + +import { KrunkitHelper } from './krunkit-helper'; + +let krunkitHelper: KrunkitHelper; + +// mock the API +vi.mock('@podman-desktop/api', async () => { + return { + process: { + exec: vi.fn(), + }, + }; +}); + +beforeEach(() => { + krunkitHelper = new KrunkitHelper(); + vi.resetAllMocks(); +}); + +test('should grab correct version', async () => { + const output = `krunkit 0.1.2 +`; + + (extensionApi.process.exec as Mock).mockReturnValue({ + stdout: output, + } as extensionApi.RunResult); + + // use a specific arch for the test + const version = await krunkitHelper.getKrunkitVersion(); + + expect(version).toBe('0.1.2'); + + // expect called with qemu-system-aarch64 (as it's arm64) + expect(extensionApi.process.exec).toHaveBeenCalledWith('krunkit', ['--version'], undefined); +}); + +test('should grab correct version using a given path', async () => { + const output = `krunkit 0.1.2 +`; + + (extensionApi.process.exec as Mock).mockReturnValue({ + stdout: output, + } as extensionApi.RunResult); + + const fakePath = '/my-dummy-path'; + + const version = await krunkitHelper.getKrunkitVersion(fakePath); + + expect(version).toBe('0.1.2'); + + expect(extensionApi.process.exec).toHaveBeenCalledWith('krunkit', ['--version'], { + env: { + PATH: fakePath, + }, + }); +}); diff --git a/extensions/podman/src/krunkit-helper.ts b/extensions/podman/src/krunkit-helper.ts new file mode 100644 index 00000000000..ef022fd0c1f --- /dev/null +++ b/extensions/podman/src/krunkit-helper.ts @@ -0,0 +1,39 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import * as extensionApi from '@podman-desktop/api'; + +export class KrunkitHelper { + async getKrunkitVersion(krunkitPath?: string): Promise { + const binaryName = 'krunkit'; + // grab output of the krunkit CLI + let env; + if (krunkitPath) { + env = { + env: { + PATH: krunkitPath, + }, + }; + } + const { stdout } = await extensionApi.process.exec(binaryName, ['--version'], env); + // stdout is like krunkit 0.1.2 + + // extract 0.1.2 from the string krunkit 0.1.2 + return RegExp(/krunkit ([0-9.]+)/).exec(stdout)?.[1]; + } +}