Skip to content

Commit 6b38132

Browse files
committed
feat(macadam): only install macadam when vm features are used
### What does this PR do? Macadam is used to run VM's, this was previously activated on boot and would install macadam immediately by showing a sudo prompt. This PR adds "lazy" installing functionality where we only prompt if VM features are clicked / used. This PR: * Moves the init functionality to only happen when creating a VM * The VM monitoring loop is triggered either at the beginning (if macadam is already installed) or after create VM is used which would be the first point a VM is accessed / used * Adds documentation as well as screenshot ### Screenshot / video of UI <!-- If this PR is changing UI, please include screenshots or screencasts showing the difference --> ### What issues does this PR fix or reference? <!-- Include any related issues from Podman Desktop repository (or from another issue tracker). --> Closes #2171 ### How to test this PR? <!-- Please explain steps to reproduce --> 1. REMOVE MACADAM (macOS): ```sh ~ $ whereis macadam macadam: /opt/macadam/bin/macadam ~ $ sudo rm -rf /opt/macadam Password: ``` If you are on Linux, it must be removed at: `/usr/local/bin/macadam` 2. Start bootc extension, you should NO LONGER receive the sudo prompt. 3. Go to **Disk Images > Create VM** 4. Expect the sudo prompt to appear to install 5. Be able to create virtual machine / list VM functionality like usual. Signed-off-by: Charlie Drage <charlie@charliedrage.com>
1 parent a69de4a commit 6b38132

File tree

6 files changed

+237
-11
lines changed

6 files changed

+237
-11
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,12 @@ RUN echo "root:root" | chpasswd
189189
190190
![](https://raw.githubusercontent.com/podman-desktop/podman-desktop-extension-bootc/main/docs/img/vm.gif)
191191

192+
> **Note (macOS and Linux):** When performing virtual machine operations (create, start, stop, delete) for the first time, you will be prompted for your password to install the [macadam](https://github.com/crc-org/macadam) binary. This is a one-time installation that enables VM management. On macOS, the binary is installed to `/opt/macadam/bin/macadam`. On Linux, the binary will be installed automatically, but if issues occur it can be [installed manually](#linux-only-unable-to-create-virtual-machine).
193+
194+
![Escalated privileges prompt](https://raw.githubusercontent.com/podman-desktop/podman-desktop-extension-bootc/main/docs/img/escalated_privileges.png)
195+
196+
> **Note (Windows):** Windows support is currently _not supported_ due to Hyper-V limitations (must run as a privileged user), WSL2 support is _not supported_ due to Windows using a custom kernel for Virtual Machine usage which is incompatible with bootc-based images.
197+
192198
## Advanced usage
193199

194200
![](https://raw.githubusercontent.com/podman-desktop/podman-desktop-extension-bootc/main/docs/img/balena_etcher.png)

docs/img/escalated_privileges.png

225 KB
Loading

packages/backend/src/extension.spec.ts

Lines changed: 162 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
import { afterEach, beforeEach, expect, test, vi, describe } from 'vitest';
2020
import * as podmanDesktopApi from '@podman-desktop/api';
21-
import { activate, deactivate, openBuildPage } from './extension';
21+
import { activate, deactivate, openBuildPage, getJSONMachineListByProvider } from './extension';
2222
import * as fs from 'node:fs';
2323
import os from 'node:os';
2424

@@ -30,6 +30,10 @@ const mocks = vi.hoisted(() => ({
3030
logErrorMock: vi.fn(),
3131
consoleLogMock: vi.fn(),
3232
consoleWarnMock: vi.fn(),
33+
consoleErrorMock: vi.fn(),
34+
macadamInitMock: vi.fn(),
35+
macadamListVmsMock: vi.fn(),
36+
existsSyncMock: vi.fn(),
3337
}));
3438

3539
vi.mock('../package.json', () => ({
@@ -38,6 +42,19 @@ vi.mock('../package.json', () => ({
3842
},
3943
}));
4044

45+
// Mock the fs module to control existsSync behavior since we're checking for binary existences
46+
vi.mock('node:fs', async importOriginal => {
47+
const actual = await importOriginal<typeof fs>();
48+
return {
49+
...actual,
50+
default: {
51+
...actual,
52+
existsSync: mocks.existsSyncMock,
53+
},
54+
existsSync: mocks.existsSyncMock,
55+
};
56+
});
57+
4158
vi.mock('@podman-desktop/api', async () => {
4259
return {
4360
version: '1.8.0',
@@ -47,6 +64,9 @@ vi.mock('@podman-desktop/api', async () => {
4764
logUsage: mocks.logUsageMock,
4865
logError: mocks.logErrorMock,
4966
}) as unknown as podmanDesktopApi.TelemetryLogger,
67+
isMac: false,
68+
isWindows: false,
69+
isLinux: false,
5070
},
5171
commands: {
5272
registerCommand: vi.fn(),
@@ -86,14 +106,20 @@ vi.mock('@podman-desktop/api', async () => {
86106
};
87107
});
88108

89-
vi.mock(import('@crc-org/macadam.js'), () => ({
90-
Macadam: vi.fn(),
109+
vi.mock('@crc-org/macadam.js', () => ({
110+
Macadam: vi.fn(
111+
class {
112+
init = mocks.macadamInitMock;
113+
listVms = mocks.macadamListVmsMock;
114+
},
115+
),
91116
}));
92117

93118
beforeEach(() => {
94119
vi.clearAllMocks();
95120
console.log = mocks.consoleLogMock;
96121
console.warn = mocks.consoleWarnMock;
122+
console.error = mocks.consoleErrorMock;
97123
});
98124

99125
afterEach(() => {
@@ -107,6 +133,65 @@ const fakeContext = {
107133
storagePath: os.tmpdir(),
108134
} as unknown as podmanDesktopApi.ExtensionContext;
109135

136+
describe('test ensureMacadamInitialized doesnt double init', () => {
137+
beforeEach(() => {
138+
vi.spyOn(fs.promises, 'readFile').mockImplementation(() => {
139+
return Promise.resolve('<html></html>');
140+
});
141+
(podmanDesktopApi.version as string) = '1.8.0';
142+
});
143+
144+
test('should propagate init error when lazy initializing via getJSONMachineListByProvider', async () => {
145+
vi.mocked(podmanDesktopApi.env).isMac = true;
146+
vi.mocked(podmanDesktopApi.env).isWindows = false;
147+
mocks.existsSyncMock.mockReturnValue(false);
148+
mocks.macadamInitMock.mockRejectedValue(new Error('Init failed'));
149+
150+
await activate(fakeContext);
151+
152+
const result = await getJSONMachineListByProvider('applehv');
153+
expect(result.error).toContain('Init failed');
154+
expect(mocks.macadamListVmsMock).not.toHaveBeenCalled();
155+
});
156+
157+
test('should lazily initialize macadam when listing VMs and binary was not installed at activation', async () => {
158+
vi.mocked(podmanDesktopApi.env).isMac = true;
159+
vi.mocked(podmanDesktopApi.env).isWindows = false;
160+
mocks.existsSyncMock.mockReturnValue(false);
161+
mocks.macadamInitMock.mockResolvedValue(undefined);
162+
mocks.macadamListVmsMock.mockResolvedValue([]);
163+
164+
// First activate without binary existing
165+
await activate(fakeContext);
166+
expect(mocks.macadamInitMock).not.toHaveBeenCalled();
167+
168+
// trigger getJSONMachineListByProvider which would call init / list since the binary did not exist at activation
169+
await getJSONMachineListByProvider('applehv');
170+
171+
// Make sure that init and listVms were called (meaning init was triggered and is going to install)
172+
expect(mocks.macadamInitMock).toHaveBeenCalled();
173+
expect(mocks.macadamListVmsMock).toHaveBeenCalled();
174+
});
175+
176+
test('should not re-initialize macadam if already initialized', async () => {
177+
vi.mocked(podmanDesktopApi.env).isMac = true;
178+
vi.mocked(podmanDesktopApi.env).isWindows = false;
179+
mocks.existsSyncMock.mockReturnValue(true);
180+
mocks.macadamInitMock.mockResolvedValue(undefined);
181+
mocks.macadamListVmsMock.mockResolvedValue([]);
182+
183+
// Activate with binary existing (will call init)
184+
await activate(fakeContext);
185+
expect(mocks.macadamInitMock).toHaveBeenCalledTimes(1);
186+
187+
// Run getJSONMachineListByProvider again!
188+
await getJSONMachineListByProvider('applehv');
189+
190+
// Should NOT re-call init since we already activated it before, so we stick to 1
191+
expect(mocks.macadamInitMock).toHaveBeenCalledTimes(1);
192+
});
193+
});
194+
110195
test('check activate', async () => {
111196
vi.spyOn(fs.promises, 'readFile').mockImplementation(() => {
112197
return Promise.resolve('<html></html>');
@@ -194,3 +279,77 @@ test('check command triggers webview and redirects', async () => {
194279
expect(podmanDesktopApi.navigation.navigateToWebview).toHaveBeenCalled();
195280
expect(postMessageMock).toHaveBeenCalledWith({ body: 'build/latest', id: 'navigate-build' });
196281
});
282+
283+
describe('lazy macadam initialization', () => {
284+
beforeEach(() => {
285+
vi.spyOn(fs.promises, 'readFile').mockImplementation(() => {
286+
return Promise.resolve('<html></html>');
287+
});
288+
(podmanDesktopApi.version as string) = '1.8.0';
289+
});
290+
291+
test('macOs: should NOT initialize macadam on activate when binary does not exist', async () => {
292+
vi.mocked(podmanDesktopApi.env).isMac = true;
293+
vi.mocked(podmanDesktopApi.env).isWindows = false;
294+
mocks.existsSyncMock.mockReturnValue(false);
295+
296+
await activate(fakeContext);
297+
298+
expect(mocks.macadamInitMock).not.toHaveBeenCalled();
299+
});
300+
301+
test('macOS: should initialize macadam on activate when binary exists', async () => {
302+
vi.mocked(podmanDesktopApi.env).isMac = true;
303+
vi.mocked(podmanDesktopApi.env).isWindows = false;
304+
mocks.existsSyncMock.mockReturnValue(true);
305+
mocks.macadamInitMock.mockResolvedValue(undefined);
306+
307+
await activate(fakeContext);
308+
309+
expect(mocks.macadamInitMock).toHaveBeenCalled();
310+
});
311+
312+
test('linux: should NOT initialize macadam on activate when binary does not exist', async () => {
313+
vi.mocked(podmanDesktopApi.env).isMac = false;
314+
vi.mocked(podmanDesktopApi.env).isWindows = false;
315+
vi.mocked(podmanDesktopApi.env).isLinux = true;
316+
mocks.existsSyncMock.mockReturnValue(false);
317+
318+
await activate(fakeContext);
319+
320+
expect(mocks.macadamInitMock).not.toHaveBeenCalled();
321+
});
322+
323+
test('linux: should initialize macadam on activate when binary exists', async () => {
324+
vi.mocked(podmanDesktopApi.env).isMac = false;
325+
vi.mocked(podmanDesktopApi.env).isWindows = false;
326+
vi.mocked(podmanDesktopApi.env).isLinux = true;
327+
mocks.existsSyncMock.mockReturnValue(true);
328+
mocks.macadamInitMock.mockResolvedValue(undefined);
329+
330+
await activate(fakeContext);
331+
332+
expect(mocks.macadamInitMock).toHaveBeenCalled();
333+
});
334+
335+
test('windows: should skip macadam initialization on activate, since macadam isnt added yet', async () => {
336+
vi.mocked(podmanDesktopApi.env).isMac = false;
337+
vi.mocked(podmanDesktopApi.env).isWindows = true;
338+
vi.mocked(podmanDesktopApi.env).isLinux = false;
339+
340+
await activate(fakeContext);
341+
342+
expect(mocks.macadamInitMock).not.toHaveBeenCalled();
343+
});
344+
345+
test('mac: should handle macadam init error gracefully during activate', async () => {
346+
vi.mocked(podmanDesktopApi.env).isMac = true;
347+
vi.mocked(podmanDesktopApi.env).isWindows = false;
348+
mocks.existsSyncMock.mockReturnValue(true);
349+
mocks.macadamInitMock.mockRejectedValue(new Error('Init failed'));
350+
351+
await activate(fakeContext);
352+
353+
expect(mocks.consoleErrorMock).toHaveBeenCalledWith('Error initializing macadam', expect.any(Error));
354+
});
355+
});

packages/backend/src/extension.ts

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { Messages } from '/@shared/src/messages/Messages';
2727
import { satisfies, minVersion, coerce } from 'semver';
2828
import { engines } from '../package.json';
2929
import * as macadamJSPackage from '@crc-org/macadam.js';
30+
import { MACADAM_MACOS_PATH } from '@crc-org/macadam.js/dist/consts';
3031
import { isHyperVEnabled, isWSLEnabled } from './macadam/win/utils';
3132
import { getErrorMessage, verifyContainerProivder } from './macadam/utils';
3233
import { LoggerDelegator } from './macadam/logger';
@@ -47,6 +48,14 @@ const currentConnections = new Map<string, extensionApi.Disposable>();
4748
let wslAndHypervEnabledContextValue = false;
4849
const WSL_HYPERV_ENABLED_KEY = 'macadam.wslHypervEnabled';
4950

51+
// Macadam binary paths - used to check if already installed
52+
// MACADAM_MACOS_PATH is exported from macadam.js, Linux path is hardcoded in the library
53+
const MACADAM_BINARY_PATH_MACOS = `${MACADAM_MACOS_PATH}/macadam`;
54+
const MACADAM_BINARY_PATH_LINUX = '/usr/local/bin/macadam';
55+
let macadamInitialized = false;
56+
let macadamProvider: extensionApi.Provider | undefined;
57+
let macadamExtensionContext: ExtensionContext | undefined;
58+
5059
const listeners = new Set<StatusHandler>();
5160

5261
export interface BinaryInfo {
@@ -178,17 +187,29 @@ export async function activate(extensionContext: ExtensionContext): Promise<void
178187

179188
if (!isWindows()) {
180189
macadam = new macadamJSPackage.Macadam(macadamName);
181-
try {
182-
await macadam.init();
183-
} catch (error) {
184-
console.error('Error initializing macadam', error);
185-
}
186190

187191
const provider = await createProvider(extensionContext);
188192

189-
monitorMachines(provider, extensionContext).catch((error: unknown) => {
190-
console.error('Error while monitoring machines', error);
191-
});
193+
// Store references for ensureMacadamInitialized() to use later
194+
macadamProvider = provider;
195+
macadamExtensionContext = extensionContext;
196+
197+
// Only initialize and start monitoring if macadam binary is already installed.
198+
// This avoids prompting for sudo on extension activation (macOS).
199+
// If not installed, init() will be called lazily when user performs a VM operation.
200+
const macadamBinaryPath = extensionApi.env.isMac ? MACADAM_BINARY_PATH_MACOS : MACADAM_BINARY_PATH_LINUX;
201+
if (fs.existsSync(macadamBinaryPath)) {
202+
try {
203+
await macadam.init();
204+
macadamInitialized = true;
205+
} catch (error) {
206+
console.error('Error initializing macadam', error);
207+
}
208+
209+
monitorMachines(provider, extensionContext).catch((error: unknown) => {
210+
console.error('Error while monitoring machines', error);
211+
});
212+
}
192213
}
193214
}
194215

@@ -234,6 +255,31 @@ export async function getConfigurationValue<T>(property: string): Promise<T | un
234255
return extensionApi.configuration.getConfiguration('bootc').get<T>(property);
235256
}
236257

258+
// Ensures macadam is initialized before VM operations.
259+
// This allows deferring the sudo prompt until the user actually tries to use VM features,
260+
// rather than prompting on extension activation.
261+
// Exported so MacadamHandler can trigger monitoring after VM creation.
262+
export async function ensureMacadamInitialized(): Promise<void> {
263+
if (macadamInitialized) {
264+
return;
265+
}
266+
267+
try {
268+
await macadam.init();
269+
macadamInitialized = true;
270+
271+
// Start monitoring now that macadam is initialized
272+
if (macadamProvider && macadamExtensionContext) {
273+
monitorMachines(macadamProvider, macadamExtensionContext).catch((error: unknown) => {
274+
console.error('Error while monitoring machines', error);
275+
});
276+
}
277+
} catch (error) {
278+
console.error('Error initializing macadam', error);
279+
throw error;
280+
}
281+
}
282+
237283
async function getJSONMachineList(): Promise<MachineJSONListOutput> {
238284
const vmProviders: (string | undefined)[] = [];
239285

@@ -284,6 +330,7 @@ export async function getJSONMachineListByProvider(vmProvider?: string): Promise
284330
let stdout: macadamJSPackage.VmDetails[] = [];
285331
let stderr = '';
286332
try {
333+
await ensureMacadamInitialized();
287334
stdout = await macadam.listVms({ containerProvider: verifyContainerProivder(vmProvider ?? '') });
288335
} catch (err: unknown) {
289336
stderr = `${err}`;
@@ -305,6 +352,7 @@ async function startMachine(
305352
const startTime = performance.now();
306353

307354
try {
355+
await ensureMacadamInitialized();
308356
await macadam.startVm({
309357
name: machineInfo.name,
310358
containerProvider: verifyContainerProivder(machineInfo.vmType),
@@ -334,6 +382,7 @@ async function stopMachine(
334382
const telemetryRecords: Record<string, unknown> = {};
335383
telemetryRecords.provider = 'macadam';
336384
try {
385+
await ensureMacadamInitialized();
337386
await macadam.stopVm({
338387
name: machineInfo.name,
339388
containerProvider: verifyContainerProivder(machineInfo.vmType),
@@ -365,6 +414,7 @@ async function registerProviderFor(
365414
await stopMachine(provider, machineInfo, context, logger);
366415
},
367416
delete: async (logger): Promise<void> => {
417+
await ensureMacadamInitialized();
368418
await macadam.removeVm({
369419
name: machineInfo.name,
370420
containerProvider: verifyContainerProivder(machineInfo.vmType),

packages/backend/src/macadam.spec.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ import { MacadamHandler } from './macadam';
2121
import * as macadam from '@crc-org/macadam.js';
2222
import * as extensionApi from '@podman-desktop/api';
2323

24+
// Mock ensureMacadamInitialized from extension.ts
25+
vi.mock('./extension', () => ({
26+
ensureMacadamInitialized: vi.fn().mockResolvedValue(undefined),
27+
}));
28+
2429
const TELEMETRY_LOGGER_MOCK: extensionApi.TelemetryLogger = {
2530
logUsage: vi.fn(),
2631
} as unknown as extensionApi.TelemetryLogger;

packages/backend/src/macadam.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type { CreateVmOptions, VmDetails } from '@crc-org/macadam.js';
2020
import { macadamName } from './constants';
2121
import * as extensionApi from '@podman-desktop/api';
2222
import { isWSLEnabled } from './macadam/win/utils';
23+
import { ensureMacadamInitialized } from './extension';
2324

2425
interface StderrError extends Error {
2526
stderr?: string;
@@ -65,6 +66,11 @@ export class MacadamHandler {
6566
await this.macadam.createVm(options);
6667
telemetryData.success = true;
6768
progress.report({ increment: 100 });
69+
70+
// Trigger the extension.ts monitoring loop now that macadam is initialized
71+
// and a VM has been created. This ensures the new VM appears in the Resources list after creation.
72+
// Creating a VM is the area where macadam is first initialized and the "install macadam" sudo prompt pops up.
73+
await ensureMacadamInitialized();
6874
},
6975
)
7076
.catch((e: unknown) => {

0 commit comments

Comments
 (0)