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 <fbenoit@redhat.com>
This commit is contained in:
Florent BENOIT 2024-04-02 11:32:06 +02:00 committed by GitHub
parent d366e775e0
commit 67ee7ef9fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 313 additions and 27 deletions

View file

@ -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"
}
],
[

View file

@ -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();
});

View file

@ -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<void> {
// 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<v
if (installedPodman) {
shouldCleanMachine = shouldNotifyQemuMachinesWithV5(installedPodman);
}
// check if the machine needs to be cleaned for v4 --> 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 (result === 'No') {
return;
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;
}
}
}
const errors: string[] = [];
for (const folder of foldersToRemove) {
// 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,
};
}),
);
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[] = [];
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,