From 67ee7ef9fd96627e70da2de961e69baa8420cd66 Mon Sep 17 00:00:00 2001 From: Florent BENOIT Date: Tue, 2 Apr 2024 11:32:06 +0200 Subject: [PATCH] feat: detect podman v4 machines not compliant with the new format of v5 (#6570) * feat: detect podman v4 machines not compliant with the new format of podman v5 fixes https://github.com/containers/podman-desktop/issues/6566 Signed-off-by: Florent Benoit --- extensions/podman/package.json | 2 +- extensions/podman/src/extension.spec.ts | 166 +++++++++++++++++++++-- extensions/podman/src/extension.ts | 170 +++++++++++++++++++++--- 3 files changed, 312 insertions(+), 26 deletions(-) diff --git a/extensions/podman/package.json b/extensions/podman/package.json index 066f7dc9d7dc0..6d30244988ae6 100644 --- a/extensions/podman/package.json +++ b/extensions/podman/package.json @@ -296,7 +296,7 @@ "content": [ [ { - "value": "Podman Machines created with podman v4 are no longer supported by podman v5" + "value": "Podman Machines created by Podman v4 are no longer supported by v5" } ], [ diff --git a/extensions/podman/src/extension.spec.ts b/extensions/podman/src/extension.spec.ts index 8da28426e01d0..f5fa2e1ac0689 100644 --- a/extensions/podman/src/extension.spec.ts +++ b/extensions/podman/src/extension.spec.ts @@ -142,7 +142,8 @@ beforeEach(() => { VMType: 'wsl', }, }; - + vi.resetAllMocks(); + extension.resetShouldNotifySetup(); (extensionApi.env.createTelemetryLogger as Mock).mockReturnValue(telemetryLogger); extension.initTelemetryLogger(); @@ -259,7 +260,7 @@ vi.mock('./util', async () => { }); beforeEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); console.error = consoleErrorMock; }); @@ -1136,11 +1137,19 @@ test('if there are no machines, make sure checkDefaultMachine is not being ran i expect(spyCheckDefaultMachine).not.toBeCalled(); }); -describe('initCheckAndRegisterUpdate', () => { - beforeEach(() => { - vi.resetAllMocks(); +test('Should notify clean machine if getJSONMachineList is erroring due to an invalid format on mac', async () => { + vi.mocked(isMac).mockReturnValue(true); + vi.spyOn(extensionApi.process, 'exec').mockRejectedValue({ + name: 'name', + message: 'description', + stderr: 'cannot unmarshal string', }); + await expect(extension.updateMachines(provider)).rejects.toThrow('description'); + expect(extensionApi.window.showNotification).toBeCalled(); + expect(extensionApi.context.setValue).toBeCalledWith(extension.CLEANUP_REQUIRED_MACHINE_KEY, true); +}); +describe('initCheckAndRegisterUpdate', () => { test('check there is an update', async () => { const podmanInstall = { checkForUpdate: vi.fn(), @@ -1299,6 +1308,7 @@ describe('initCheckAndRegisterUpdate', () => { describe('registerOnboardingMachineExistsCommand', () => { test('check with error when calling podman machine ls command', async () => { + vi.mocked(isMac).mockReturnValue(true); vi.mocked(extensionApi.commands.registerCommand).mockReturnValue({ dispose: vi.fn() }); vi.mocked(extensionApi.process.exec).mockRejectedValue(new Error('error')); @@ -1324,6 +1334,8 @@ describe('registerOnboardingMachineExistsCommand', () => { }); test('check with 2 machines', async () => { + vi.mocked(isMac).mockReturnValue(true); + vi.mocked(extensionApi.commands.registerCommand).mockReturnValue({ dispose: vi.fn() }); // return 2 empty machines @@ -1350,6 +1362,8 @@ describe('registerOnboardingMachineExistsCommand', () => { }); test('check with 0 machine', async () => { + vi.mocked(isMac).mockReturnValue(true); + vi.mocked(extensionApi.commands.registerCommand).mockReturnValue({ dispose: vi.fn() }); // return empty machine array @@ -1378,14 +1392,21 @@ describe('registerOnboardingMachineExistsCommand', () => { describe('registerOnboardingUnsupportedPodmanMachineCommand', () => { test('check with v5 and previous qemu folders', async () => { + vi.mocked(isMac).mockReturnValue(true); + vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(extensionApi.commands.registerCommand).mockReturnValue({ dispose: vi.fn() }); - vi.mocked(extensionApi.process.exec).mockResolvedValue({ + vi.mocked(extensionApi.process.exec).mockResolvedValueOnce({ stdout: 'podman version 5.0.0', } as unknown as extensionApi.RunResult); + // second call to get the machine list + vi.mocked(extensionApi.process.exec).mockResolvedValueOnce({ + stdout: '[]', + } as unknown as extensionApi.RunResult); + // perform the call const disposable = registerOnboardingUnsupportedPodmanMachineCommand(); @@ -1407,15 +1428,23 @@ describe('registerOnboardingUnsupportedPodmanMachineCommand', () => { }); test('check with v5 and no previous qemu folders', async () => { + vi.mocked(isMac).mockReturnValue(true); + // no qemu folders vi.mocked(fs.existsSync).mockReturnValue(false); vi.mocked(extensionApi.commands.registerCommand).mockReturnValue({ dispose: vi.fn() }); - vi.mocked(extensionApi.process.exec).mockResolvedValue({ + // first call to get the podman version + vi.mocked(extensionApi.process.exec).mockResolvedValueOnce({ stdout: 'podman version 5.0.0', } as unknown as extensionApi.RunResult); + // second call to get the machine list + vi.mocked(extensionApi.process.exec).mockResolvedValueOnce({ + stdout: '[]', + } as unknown as extensionApi.RunResult); + // perform the call const disposable = registerOnboardingUnsupportedPodmanMachineCommand(); @@ -1437,14 +1466,21 @@ describe('registerOnboardingUnsupportedPodmanMachineCommand', () => { }); test('check with v4 and qemu folders', async () => { + vi.mocked(isMac).mockReturnValue(true); + vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(extensionApi.commands.registerCommand).mockReturnValue({ dispose: vi.fn() }); - vi.mocked(extensionApi.process.exec).mockResolvedValue({ + vi.mocked(extensionApi.process.exec).mockResolvedValueOnce({ stdout: 'podman version 4.9.3', } as unknown as extensionApi.RunResult); + // second call to get the machine list + vi.mocked(extensionApi.process.exec).mockResolvedValueOnce({ + stdout: '[]', + } as unknown as extensionApi.RunResult); + // perform the call const disposable = registerOnboardingUnsupportedPodmanMachineCommand(); @@ -1464,10 +1500,50 @@ describe('registerOnboardingUnsupportedPodmanMachineCommand', () => { // check called with false as there are qemu folders but we're with podman v4 expect(extensionApi.context.setValue).toBeCalledWith('unsupportedPodmanMachine', false, 'onboarding'); }); + + test('check with v5 and error in JSON of machines', async () => { + vi.mocked(isMac).mockReturnValue(true); + + // no qemu folders + vi.mocked(fs.existsSync).mockReturnValue(false); + + vi.mocked(extensionApi.commands.registerCommand).mockReturnValue({ dispose: vi.fn() }); + + // first call to get the podman version + vi.mocked(extensionApi.process.exec).mockResolvedValueOnce({ + stdout: 'podman version 5.0.0', + } as unknown as extensionApi.RunResult); + + // second call to get the machine list + vi.mocked(extensionApi.process.exec).mockRejectedValue({ + stderr: 'incompatible machine config', + } as unknown as extensionApi.RunResult); + + // perform the call + const disposable = registerOnboardingUnsupportedPodmanMachineCommand(); + + // checks + expect(disposable).toBeDefined(); + + // check command is called + expect(extensionApi.commands.registerCommand).toBeCalledWith( + 'podman.onboarding.checkUnsupportedPodmanMachine', + expect.any(Function), + ); + + const func = vi.mocked(extensionApi.commands.registerCommand).mock.calls[0][1]; + // call the function + await func(); + + // check it is false as there are no qemu folders + expect(extensionApi.context.setValue).toBeCalledWith('unsupportedPodmanMachine', true, 'onboarding'); + }); }); describe('registerOnboardingRemoveUnsupportedMachinesCommand', () => { test('check with previous qemu folders', async () => { + vi.mocked(isMac).mockReturnValue(true); + vi.mocked(fs.existsSync).mockReturnValue(true); // mock confirmation window message to true @@ -1475,10 +1551,14 @@ describe('registerOnboardingRemoveUnsupportedMachinesCommand', () => { vi.mocked(extensionApi.commands.registerCommand).mockReturnValue({ dispose: vi.fn() }); - vi.mocked(extensionApi.process.exec).mockResolvedValue({ + vi.mocked(extensionApi.process.exec).mockResolvedValueOnce({ stdout: 'podman version 5.0.0', } as unknown as extensionApi.RunResult); + vi.mocked(extensionApi.process.exec).mockResolvedValueOnce({ + stdout: '[]', + } as unknown as extensionApi.RunResult); + // perform the call const disposable = extension.registerOnboardingRemoveUnsupportedMachinesCommand(); @@ -1505,4 +1585,72 @@ describe('registerOnboardingRemoveUnsupportedMachinesCommand', () => { // check called with true as there are qemu folders expect(extensionApi.context.setValue).toBeCalledWith('unsupportedMachineRemoved', 'ok', 'onboarding'); }); + + test('check with previous podman v4 config files', async () => { + vi.mocked(isMac).mockReturnValue(true); + + // mock confirmation window message to true + vi.mocked(extensionApi.window.showWarningMessage).mockResolvedValue('Yes'); + + vi.mocked(extensionApi.commands.registerCommand).mockReturnValue({ dispose: vi.fn() }); + + vi.mocked(extensionApi.process.exec).mockResolvedValueOnce({ + stdout: 'podman version 5.0.0', + } as unknown as extensionApi.RunResult); + // two times false (no qemu folders) + vi.mocked(fs.existsSync).mockReturnValueOnce(false); + vi.mocked(fs.existsSync).mockReturnValueOnce(false); + + // return an error when trying to list output + vi.mocked(fs.existsSync).mockReturnValueOnce(true); + vi.mocked(extensionApi.process.exec).mockResolvedValueOnce({ + stdout: '[]', + stderr: 'incompatible machine config', + } as unknown as extensionApi.RunResult); + + vi.mocked(fs.promises.readdir).mockResolvedValue(['foo.json'] as unknown as fs.Dirent[]); + + // mock readfile + vi.mocked(fs.promises.readFile).mockResolvedValueOnce('{"Driver": "podman"}'); + + // perform the call + const disposable = extension.registerOnboardingRemoveUnsupportedMachinesCommand(); + + // checks + expect(disposable).toBeDefined(); + + // check command is called + expect(extensionApi.commands.registerCommand).toBeCalledWith( + 'podman.onboarding.removeUnsupportedMachines', + expect.any(Function), + ); + + const func = vi.mocked(extensionApi.commands.registerCommand).mock.calls[0][1]; + // call the function + await func(); + + // expect rm to be called + expect(fs.promises.rm).toBeCalledWith(expect.stringContaining('foo.json'), { + recursive: true, + maxRetries: 3, + retryDelay: 1000, + }); + + // check called with true as there are qemu folders + expect(extensionApi.context.setValue).toBeCalledWith('unsupportedMachineRemoved', 'ok', 'onboarding'); + }); +}); + +test('isIncompatibleMachineOutput', () => { + const emptyResponse = extension.isIncompatibleMachineOutput(undefined); + expect(emptyResponse).toBeFalsy(); + + const unknownErrorResponse = extension.isIncompatibleMachineOutput('unknown error'); + expect(unknownErrorResponse).toBeFalsy(); + + const wslErrorResponse = extension.isIncompatibleMachineOutput('cannot unmarshal string'); + expect(wslErrorResponse).toBeTruthy(); + + const applehvErrorResponse = extension.isIncompatibleMachineOutput('incompatible machine config'); + expect(applehvErrorResponse).toBeTruthy(); }); diff --git a/extensions/podman/src/extension.ts b/extensions/podman/src/extension.ts index 90f3414a946d7..028306dd4d559 100644 --- a/extensions/podman/src/extension.ts +++ b/extensions/podman/src/extension.ts @@ -125,12 +125,33 @@ export type MachineListOutput = { stderr: string; }; +export function isIncompatibleMachineOutput(output: string | undefined): boolean { + // apple HV v4 to v5 machine config error + const APPLE_HV_V4_V5_ERROR = 'incompatible machine config'; + + // wsl v4 to v5 machine config error + const WSL_V4_V5_ERROR = 'cannot unmarshal string'; + + if (output) { + return output.includes(APPLE_HV_V4_V5_ERROR) || output.includes(WSL_V4_V5_ERROR); + } else { + return false; + } +} + export async function updateMachines(provider: extensionApi.Provider): Promise { // init machines available let machineListOutput: MachineListOutput; try { machineListOutput = await getJSONMachineList(); } catch (error) { + let shouldCleanMachine = false; + // check if field stderr is present in the error object + if (error.stderr) { + shouldCleanMachine = isIncompatibleMachineOutput(error.stderr); + } + extensionApi.context.setValue(CLEANUP_REQUIRED_MACHINE_KEY, shouldCleanMachine); + // Only on macOS and Windows should we show the setup notification // if for some reason doing getJSONMachineList fails.. if (shouldNotifySetup && !isLinux()) { @@ -150,6 +171,18 @@ export async function updateMachines(provider: extensionApi.Provider): Promise v5 format + if (!shouldCleanMachine) { + shouldCleanMachine = isIncompatibleMachineOutput(machineListOutput.stderr); + } + + // invalid machines is not making the provider working properly so always notify + if (shouldCleanMachine && shouldNotifySetup && !isLinux()) { + // push setup notification + notificationDisposable = extensionApi.window.showNotification(setupPodmanNotification); + shouldCleanMachine = false; + } + extensionApi.context.setValue(CLEANUP_REQUIRED_MACHINE_KEY, shouldCleanMachine); // Only show the notification on macOS and Windows @@ -920,40 +953,128 @@ export function registerOnboardingUnsupportedPodmanMachineCommand(): extensionAp isUnsupported = shouldNotifyQemuMachinesWithV5(installedPodman); } + // check if the machine needs to be cleaned for v4 --> v5 format + if (!isUnsupported) { + try { + const machineListOutput = await getJSONMachineList(); + isUnsupported = isIncompatibleMachineOutput(machineListOutput.stderr); + } catch (error) { + // check if stderr in the error object + if (error.stderr) { + isUnsupported = isIncompatibleMachineOutput(error.stderr); + } + } + } + extensionApi.context.setValue('unsupportedPodmanMachine', isUnsupported, 'onboarding'); }); } export function registerOnboardingRemoveUnsupportedMachinesCommand(): extensionApi.Disposable { return extensionApi.commands.registerCommand('podman.onboarding.removeUnsupportedMachines', async () => { - // only on macOS - // do not check if version is v5 as it is being checked by the command that triggers this one - if (extensionApi.env.isMac) { + const fileAndFoldersToRemove = []; + const wslMachinesToUnregister = []; + + const installedPodman = await getPodmanInstallation(); + + if (extensionApi.env.isMac && installedPodman?.version.startsWith('5.')) { // remove the qemu machines folder const qemuSharePath = path.resolve(os.homedir(), appHomeDir(), 'machine', 'qemu'); const qemuConfigPath = path.resolve(os.homedir(), appConfigDir(), 'machine', 'qemu'); // remove folders if exists - const foldersToRemove = []; if (fs.existsSync(qemuSharePath)) { - foldersToRemove.push(qemuSharePath); + fileAndFoldersToRemove.push(qemuSharePath); } if (fs.existsSync(qemuConfigPath)) { - foldersToRemove.push(qemuConfigPath); + fileAndFoldersToRemove.push(qemuConfigPath); } // prompt the user to confirm - const result = await extensionApi.window.showWarningMessage( - 'Removing old unsupported Podman machines will delete all of their data. Confirm approval?', - 'Yes', - 'No', + if (fileAndFoldersToRemove.length > 0) { + const result = await extensionApi.window.showWarningMessage( + 'Removing old unsupported provider Podman machines will delete all of their data. Confirm approval?', + 'Yes', + 'No', + ); + if (result === 'No') { + return; + } + } + } + + // check if unmarshalling errors + let machineListError = ''; + try { + const machineListOutput = await getJSONMachineList(); + machineListError = machineListOutput.stderr; + } catch (error) { + machineListError = error.stderr; + } + + let machineFolderToCheck: string | undefined; + // check invalid config files only with v5 + if (installedPodman?.version.startsWith('5.')) { + if (isMac()) { + machineFolderToCheck = path.resolve(os.homedir(), appConfigDir(), 'machine', 'applehv'); + } else if (isWindows()) { + machineFolderToCheck = path.resolve(os.homedir(), appConfigDir(), 'machine', 'wsl'); + } + } + + if (machineFolderToCheck && isIncompatibleMachineOutput(machineListError) && fs.existsSync(machineFolderToCheck)) { + // check for JSON files in the folder + const files = await fs.promises.readdir(machineFolderToCheck); + const machineFilesToAnalyze = files.filter(file => file.endsWith('.json')); + let machineConfigJson: { GvProxy?: string } = {}; + const allMachines = await Promise.all( + machineFilesToAnalyze.map(async file => { + // read content of the file + const absoluteFile = path.join(machineFolderToCheck, file); + try { + const machineConfigJsonRaw = await fs.promises.readFile(absoluteFile, 'utf-8'); + machineConfigJson = JSON.parse(machineConfigJsonRaw); + } catch (error: unknown) { + console.error('Error reading machine file', file, error); + } + const machineName = file.replace('.json', ''); + return { + file, + machineName, + machineFile: absoluteFile, + json: machineConfigJson, + }; + }), ); - if (result === 'No') { - return; + + const invalidMachines = allMachines.filter(machine => { + // check if the machine has GvProxy field, if it doesn't, it's an invalid machine + return !machine.json.GvProxy; + }); + + // prompt to remove these invalid machines + if (invalidMachines.length > 0) { + const result = await extensionApi.window.showWarningMessage( + `Removing old unsupported Podman machines "${invalidMachines.map(m => m.machineName).join(', ')}" will delete all of their data. Confirm approval?`, + 'Yes', + 'No', + ); + if (result === 'No') { + return; + } + for (const machine of invalidMachines) { + fileAndFoldersToRemove.push(machine.machineFile); + if (machine.machineFile.includes('wsl') && isWindows()) { + wslMachinesToUnregister.push(machine.machineName); + } + } } + } - const errors: string[] = []; - for (const folder of foldersToRemove) { + const errors: string[] = []; + + if (fileAndFoldersToRemove.length > 0) { + for (const folder of fileAndFoldersToRemove) { try { await fs.promises.rm(folder, { recursive: true, retryDelay: 1000, maxRetries: 3 }); } catch (error) { @@ -961,10 +1082,23 @@ export function registerOnboardingRemoveUnsupportedMachinesCommand(): extensionA errors.push(`Unable to remove the folder ${folder}: ${String(error)}`); } } - if (errors.length > 0) { - await extensionApi.window.showErrorMessage(`Error removing unsupported Podman machines. ${errors.join('\n')}`); + + shouldNotifySetup = true; + // notification is no more required + notificationDisposable?.dispose(); + } + + for (const wslMachineName of wslMachinesToUnregister) { + try { + await extensionApi.process.exec('wsl', ['--unregister', wslMachineName]); + } catch (error) { + console.error('Error removing WSL machine', wslMachineName, error); + errors.push(`Unable to remove the WSL machine ${wslMachineName}: ${String(error)}`); } } + if (errors.length > 0) { + await extensionApi.window.showErrorMessage(`Error removing unsupported Podman machines. ${errors.join('\n')}`); + } extensionApi.context.setValue('unsupportedMachineRemoved', 'ok', 'onboarding'); }); @@ -1656,6 +1790,10 @@ export async function createMachine( notificationDisposable?.dispose(); } +export function resetShouldNotifySetup(): void { + shouldNotifySetup = true; +} + function setupDisguisedPodmanSocketWatcher( provider: extensionApi.Provider, socketFile: string,