diff --git a/packages/backend/src/managers/podmanConnection.spec.ts b/packages/backend/src/managers/podmanConnection.spec.ts index e9a85f864..f234b9a16 100644 --- a/packages/backend/src/managers/podmanConnection.spec.ts +++ b/packages/backend/src/managers/podmanConnection.spec.ts @@ -20,6 +20,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { PodmanConnection } from './podmanConnection'; import type { ContainerProviderConnection, + Extension, ProviderConnectionStatus, ProviderContainerConnection, ProviderEvent, @@ -29,10 +30,11 @@ import type { UpdateContainerConnectionEvent, Webview, } from '@podman-desktop/api'; -import { containerEngine, process, provider, EventEmitter, env } from '@podman-desktop/api'; +import { containerEngine, extensions, process, provider, EventEmitter, env } from '@podman-desktop/api'; import { VMType } from '@shared/src/models/IPodman'; import { Messages } from '@shared/Messages'; import type { ModelInfo } from '@shared/src/models/IModelInfo'; +import { getPodmanCli, getPodmanMachineName } from '../utils/podman'; const webviewMock = { postMessage: vi.fn(), @@ -51,6 +53,9 @@ vi.mock('@podman-desktop/api', async () => { process: { exec: vi.fn(), }, + extensions: { + getExtension: vi.fn(), + }, containerEngine: { listInfos: vi.fn(), }, @@ -64,6 +69,7 @@ vi.mock('@podman-desktop/api', async () => { vi.mock('../utils/podman', () => { return { getPodmanCli: vi.fn(), + getPodmanMachineName: vi.fn(), MIN_CPUS_VALUE: 4, }; }); @@ -73,6 +79,8 @@ beforeEach(() => { vi.mocked(webviewMock.postMessage).mockResolvedValue(true); vi.mocked(provider.getContainerConnections).mockReturnValue([]); + vi.mocked(getPodmanCli).mockReturnValue('podman-executable'); + vi.mocked(getPodmanMachineName).mockImplementation(connection => connection.name); const listeners: ((value: unknown) => void)[] = []; @@ -86,6 +94,151 @@ beforeEach(() => { } as unknown as EventEmitter); }); +const providerContainerConnectionMock: ProviderContainerConnection = { + connection: { + type: 'podman', + status: () => 'started', + name: 'Podman Machine', + endpoint: { + socketPath: './socket-path', + }, + }, + providerId: 'podman', +}; + +describe('execute', () => { + test('execute should get the podman extension from api', async () => { + vi.mocked(extensions.getExtension).mockReturnValue(undefined); + const manager = new PodmanConnection(webviewMock); + await manager.execute(providerContainerConnectionMock.connection, ['ls']); + expect(extensions.getExtension).toHaveBeenCalledWith('podman-desktop.podman'); + }); + + test('execute should call getPodmanCli if extension not available', async () => { + vi.mocked(extensions.getExtension).mockReturnValue(undefined); + const manager = new PodmanConnection(webviewMock); + await manager.execute(providerContainerConnectionMock.connection, ['ls']); + + expect(getPodmanCli).toHaveBeenCalledOnce(); + expect(process.exec).toHaveBeenCalledWith('podman-executable', ['ls'], undefined); + }); + + test('options should be propagated to process execution when provided', async () => { + vi.mocked(extensions.getExtension).mockReturnValue(undefined); + const manager = new PodmanConnection(webviewMock); + await manager.execute(providerContainerConnectionMock.connection, ['ls'], { + isAdmin: true, + }); + + expect(getPodmanCli).toHaveBeenCalledOnce(); + expect(process.exec).toHaveBeenCalledWith('podman-executable', ['ls'], { + isAdmin: true, + }); + }); + + test('execute should use extension exec if available', async () => { + vi.mocked(provider.getContainerConnections).mockReturnValue([providerContainerConnectionMock]); + const podmanAPI = { + exec: vi.fn(), + }; + vi.mocked(extensions.getExtension).mockReturnValue({ exports: podmanAPI } as unknown as Extension); + const manager = new PodmanConnection(webviewMock); + await manager.execute(providerContainerConnectionMock.connection, ['ls']); + + expect(getPodmanCli).not.toHaveBeenCalledOnce(); + expect(podmanAPI.exec).toHaveBeenCalledWith(['ls'], { + connection: providerContainerConnectionMock, + }); + }); + + test('an error should be throw if the provided container connection do not exists', async () => { + vi.mocked(provider.getContainerConnections).mockReturnValue([]); + const podmanAPI = { + exec: vi.fn(), + }; + vi.mocked(extensions.getExtension).mockReturnValue({ exports: podmanAPI } as unknown as Extension); + const manager = new PodmanConnection(webviewMock); + + await expect(async () => { + await manager.execute(providerContainerConnectionMock.connection, ['ls'], { + isAdmin: true, + }); + }).rejects.toThrowError('cannot find podman provider with connection name Podman Machine'); + }); + + test('execute should propagate options to extension exec if available', async () => { + vi.mocked(provider.getContainerConnections).mockReturnValue([providerContainerConnectionMock]); + const podmanAPI = { + exec: vi.fn(), + }; + vi.mocked(extensions.getExtension).mockReturnValue({ exports: podmanAPI } as unknown as Extension); + const manager = new PodmanConnection(webviewMock); + await manager.execute(providerContainerConnectionMock.connection, ['ls'], { + isAdmin: true, + }); + + expect(getPodmanCli).not.toHaveBeenCalledOnce(); + expect(podmanAPI.exec).toHaveBeenCalledWith(['ls'], { + isAdmin: true, + connection: providerContainerConnectionMock, + }); + }); +}); + +describe('executeSSH', () => { + test('executeSSH should call getPodmanCli if extension not available', async () => { + vi.mocked(extensions.getExtension).mockReturnValue(undefined); + const manager = new PodmanConnection(webviewMock); + await manager.executeSSH(providerContainerConnectionMock.connection, ['ls']); + + expect(getPodmanCli).toHaveBeenCalledOnce(); + expect(process.exec).toHaveBeenCalledWith( + 'podman-executable', + ['machine', 'ssh', providerContainerConnectionMock.connection.name, 'ls'], + undefined, + ); + }); + + test('executeSSH should use extension exec if available', async () => { + vi.mocked(provider.getContainerConnections).mockReturnValue([providerContainerConnectionMock]); + const podmanAPI = { + exec: vi.fn(), + }; + vi.mocked(extensions.getExtension).mockReturnValue({ exports: podmanAPI } as unknown as Extension); + const manager = new PodmanConnection(webviewMock); + await manager.executeSSH(providerContainerConnectionMock.connection, ['ls']); + + expect(getPodmanCli).not.toHaveBeenCalledOnce(); + expect(podmanAPI.exec).toHaveBeenCalledWith( + ['machine', 'ssh', providerContainerConnectionMock.connection.name, 'ls'], + { + connection: providerContainerConnectionMock, + }, + ); + }); + + test('executeSSH should propagate options to extension exec if available', async () => { + vi.mocked(provider.getContainerConnections).mockReturnValue([providerContainerConnectionMock]); + const podmanAPI = { + exec: vi.fn(), + }; + vi.mocked(extensions.getExtension).mockReturnValue({ exports: podmanAPI } as unknown as Extension); + const manager = new PodmanConnection(webviewMock); + await manager.executeSSH(providerContainerConnectionMock.connection, ['ls'], { + isAdmin: true, + }); + + expect(getPodmanCli).not.toHaveBeenCalledOnce(); + expect(podmanAPI.exec).toHaveBeenCalledWith( + ['machine', 'ssh', providerContainerConnectionMock.connection.name, 'ls'], + { + isAdmin: true, + connection: providerContainerConnectionMock, + }, + ); + }); +}); + describe('podman connection initialization', () => { test('init should notify publisher', () => { const manager = new PodmanConnection(webviewMock); diff --git a/packages/backend/src/managers/podmanConnection.ts b/packages/backend/src/managers/podmanConnection.ts index 377fb2011..79ac06abf 100644 --- a/packages/backend/src/managers/podmanConnection.ts +++ b/packages/backend/src/managers/podmanConnection.ts @@ -23,10 +23,12 @@ import type { RegisterContainerConnectionEvent, UpdateContainerConnectionEvent, Webview, + RunResult, + RunOptions, + ProviderContainerConnection, } from '@podman-desktop/api'; -import { containerEngine, env, navigation, EventEmitter, process, provider } from '@podman-desktop/api'; -import type { MachineJSON } from '../utils/podman'; -import { MIN_CPUS_VALUE, getPodmanCli } from '../utils/podman'; +import { containerEngine, env, navigation, EventEmitter, process, provider, extensions } from '@podman-desktop/api'; +import { getPodmanMachineName, type MachineJSON, MIN_CPUS_VALUE, getPodmanCli } from '../utils/podman'; import { VMType } from '@shared/src/models/IPodman'; import { Publisher } from '../utils/Publisher'; import type { @@ -40,6 +42,10 @@ export interface PodmanConnectionEvent { status: 'stopped' | 'started' | 'unregister' | 'register'; } +export interface PodmanRunOptions extends RunOptions { + connection?: ProviderContainerConnection; +} + export class PodmanConnection extends Publisher implements Disposable { // Map of providerId with corresponding connections #providers: Map; @@ -54,6 +60,71 @@ export class PodmanConnection extends Publisher { + const podman = extensions.getExtension('podman-desktop.podman'); + if (!podman) { + console.warn('cannot find podman extension api'); + return this.executeLegacy(args, options); + } + + const podmanApi: { + exec(args: string[], options?: PodmanRunOptions): Promise; + } = podman.exports; + + return podmanApi.exec(args, { + ...options, + connection: this.getProviderContainerConnection(connection), + }); + } + + /** + * Execute a command inside the podman machine + * + * @example + * ``` + * const result = await podman.executeSSH(connection, ['ls', '/dev']); + * ``` + * @param connection + * @param args + * @param options + */ + executeSSH(connection: ContainerProviderConnection, args: string[], options?: RunOptions): Promise { + return this.execute(connection, ['machine', 'ssh', this.getNameLegacyCompatibility(connection), ...args], options); + } + + /** + * Before 1.13, the podman extension was not exposing any api. + * + * Therefore, to support old version we need to get the podman executable ourself + * @deprecated + */ + protected executeLegacy(args: string[], options?: RunOptions): Promise { + return process.exec(getPodmanCli(), [...args], options); + } + + /** + * Before 1.13, the {@link ContainerProviderConnection.name} field was used as friendly user + * field also. + * + * Therefore, we could have `Podman Machine Default` as name, where the real machine was `podman-machine-default`. + * @param connection + * @deprecated + */ + protected getNameLegacyCompatibility(connection: ContainerProviderConnection): string { + return getPodmanMachineName(connection); + } + getContainerProviderConnections(): ContainerProviderConnection[] { return Array.from(this.#providers.values()).flat(); } @@ -92,11 +163,27 @@ export class PodmanConnection extends Publisher disposable.dispose()); } + /** + * This method allow us to get the ProviderContainerConnection given a ContainerProviderConnection + * @param connection + * @protected + */ + protected getProviderContainerConnection(connection: ContainerProviderConnection): ProviderContainerConnection { + const providers: ProviderContainerConnection[] = provider.getContainerConnections(); + + const podmanProvider = providers + .filter(({ connection }) => connection.type === 'podman') + .find(provider => provider.connection.name === connection.name); + if (!podmanProvider) throw new Error(`cannot find podman provider with connection name ${connection.name}`); + + return podmanProvider; + } + protected refreshProviders(): void { // clear all providers this.#providers.clear(); - const providers = provider.getContainerConnections(); + const providers: ProviderContainerConnection[] = provider.getContainerConnections(); // register the podman container connection providers