Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 154 additions & 1 deletion packages/backend/src/managers/podmanConnection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { PodmanConnection } from './podmanConnection';
import type {
ContainerProviderConnection,
Extension,
ProviderConnectionStatus,
ProviderContainerConnection,
ProviderEvent,
Expand All @@ -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(),
Expand All @@ -51,6 +53,9 @@ vi.mock('@podman-desktop/api', async () => {
process: {
exec: vi.fn(),
},
extensions: {
getExtension: vi.fn(),
},
containerEngine: {
listInfos: vi.fn(),
},
Expand All @@ -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,
};
});
Expand All @@ -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)[] = [];

Expand All @@ -86,6 +94,151 @@ beforeEach(() => {
} as unknown as EventEmitter<unknown>);
});

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<unknown>);
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<unknown>);
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<unknown>);
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<unknown>);
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<unknown>);
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);
Expand Down
95 changes: 91 additions & 4 deletions packages/backend/src/managers/podmanConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -40,6 +42,10 @@ export interface PodmanConnectionEvent {
status: 'stopped' | 'started' | 'unregister' | 'register';
}

export interface PodmanRunOptions extends RunOptions {
connection?: ProviderContainerConnection;
}

export class PodmanConnection extends Publisher<ContainerProviderConnectionInfo[]> implements Disposable {
// Map of providerId with corresponding connections
#providers: Map<string, ContainerProviderConnection[]>;
Expand All @@ -54,6 +60,71 @@ export class PodmanConnection extends Publisher<ContainerProviderConnectionInfo[
this.#disposables = [];
}

/**
* Execute the podman cli with the arguments provided
*
* @example
* ```
* const result = await podman.execute(connection, ['machine', 'ls', '--format=json']);
* ```
* @param connection
* @param args
* @param options
*/
execute(connection: ContainerProviderConnection, args: string[], options?: RunOptions): Promise<RunResult> {
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<RunResult>;
} = 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<RunResult> {
return this.execute(connection, ['machine', 'ssh', this.getNameLegacyCompatibility(connection), ...args], options);
}

/**
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure we need the code for < 1.13 as the extension is already requiring API > 1.13.0

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are not ?

We uses the latest api, but we don't have hard requirements on latest version, so we are in theory compatible with older version up to 1.8.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in the catalog it says minimum 1.12 for 1.2 of ai lab

https://github.com/containers/podman-desktop-catalog/blob/gh-pages/api/extensions.json#L689

and in package.json, we consume 1.13.0 snapshot API

I'm assuming some API changes (new additions) are also missing since 1.8

so consuming 1.13 API should result in 1.13 minimal requirement

* 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<RunResult> {
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();
}
Expand Down Expand Up @@ -92,11 +163,27 @@ export class PodmanConnection extends Publisher<ContainerProviderConnectionInfo[
this.#disposables.forEach(disposable => 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
Expand Down