From d9dc20922f98562403ec47e90e21cccb770e9e1c Mon Sep 17 00:00:00 2001 From: Luca Stocchi <49404737+lstocchi@users.noreply.github.com> Date: Wed, 13 Mar 2024 13:43:39 +0100 Subject: [PATCH] fix: upload model on podman machine on WSL to speed loading (#204) * fix: upload model on podman machine on WSL to speed loading Signed-off-by: lstocchi * fix: add tests Signed-off-by: lstocchi * fix: get running machine name and use it on podman cp/stat Signed-off-by: lstocchi * fix: fix lint, format and add tests Signed-off-by: lstocchi * fix: use machine name to execute podman cli Signed-off-by: lstocchi * fix: fix tests and reorganize based on review Signed-off-by: lstocchi * fix: rename progressiveEvent Signed-off-by: lstocchi * fix: show error if failing Signed-off-by: lstocchi * fix: calculate machine name from running connection Signed-off-by: lstocchi * fix: update remote path where to store uploaded model Signed-off-by: lstocchi --------- Signed-off-by: lstocchi --- .../src/managers/applicationManager.spec.ts | 3 + .../src/managers/applicationManager.ts | 8 +- .../src/managers/modelsManager.spec.ts | 2 + .../backend/src/managers/modelsManager.ts | 42 +++- .../backend/src/managers/playground.spec.ts | 31 +-- packages/backend/src/managers/playground.ts | 20 +- .../src/managers/podmanConnection.spec.ts | 25 ++- .../backend/src/managers/podmanConnection.ts | 8 +- packages/backend/src/models/baseEvent.ts | 49 +++++ .../backend/src/utils/WSLUploader.spec.ts | 118 ++++++++++ packages/backend/src/utils/WSLUploader.ts | 68 ++++++ packages/backend/src/utils/downloader.ts | 38 +--- packages/backend/src/utils/podman.spec.ts | 206 ++++++++++++++++++ packages/backend/src/utils/podman.ts | 88 ++++++++ packages/backend/src/utils/uploader.spec.ts | 64 ++++++ packages/backend/src/utils/uploader.ts | 86 ++++++++ 16 files changed, 765 insertions(+), 91 deletions(-) create mode 100644 packages/backend/src/models/baseEvent.ts create mode 100644 packages/backend/src/utils/WSLUploader.spec.ts create mode 100644 packages/backend/src/utils/WSLUploader.ts create mode 100644 packages/backend/src/utils/podman.spec.ts create mode 100644 packages/backend/src/utils/podman.ts create mode 100644 packages/backend/src/utils/uploader.spec.ts create mode 100644 packages/backend/src/utils/uploader.ts diff --git a/packages/backend/src/managers/applicationManager.spec.ts b/packages/backend/src/managers/applicationManager.spec.ts index abecd880c..d75b6bb9a 100644 --- a/packages/backend/src/managers/applicationManager.spec.ts +++ b/packages/backend/src/managers/applicationManager.spec.ts @@ -254,6 +254,7 @@ describe('pullApplication', () => { }); mocks.listPodsMock.mockResolvedValue([]); vi.spyOn(modelsManager, 'isModelOnDisk').mockReturnValue(false); + vi.spyOn(modelsManager, 'uploadModelToPodmanMachine').mockResolvedValue('path'); mocks.performDownloadMock.mockResolvedValue('path'); const recipe: Recipe = { id: 'recipe1', @@ -316,6 +317,7 @@ describe('pullApplication', () => { }); mocks.listPodsMock.mockResolvedValue([]); vi.spyOn(modelsManager, 'isModelOnDisk').mockReturnValue(false); + vi.spyOn(modelsManager, 'uploadModelToPodmanMachine').mockResolvedValue('path'); mocks.performDownloadMock.mockResolvedValue('path'); const recipe: Recipe = { id: 'recipe1', @@ -344,6 +346,7 @@ describe('pullApplication', () => { }); mocks.listPodsMock.mockResolvedValue([]); vi.spyOn(modelsManager, 'isModelOnDisk').mockReturnValue(true); + vi.spyOn(modelsManager, 'uploadModelToPodmanMachine').mockResolvedValue('path'); vi.spyOn(modelsManager, 'getLocalModelPath').mockReturnValue('path'); const recipe: Recipe = { id: 'recipe1', diff --git a/packages/backend/src/managers/applicationManager.ts b/packages/backend/src/managers/applicationManager.ts index 7ff7fd172..5277bd259 100644 --- a/packages/backend/src/managers/applicationManager.ts +++ b/packages/backend/src/managers/applicationManager.ts @@ -133,7 +133,13 @@ export class ApplicationManager extends Publisher { const configAndFilteredContainers = this.getConfigAndFilterContainers(recipe.config, localFolder); // get model by downloading it or retrieving locally - const modelPath = await this.modelsManager.requestDownloadModel(model, { + let modelPath = await this.modelsManager.requestDownloadModel(model, { + 'recipe-id': recipe.id, + 'model-id': model.id, + }); + + // upload model to podman machine if user system is supported + modelPath = await this.modelsManager.uploadModelToPodmanMachine(model, modelPath, { 'recipe-id': recipe.id, 'model-id': model.id, }); diff --git a/packages/backend/src/managers/modelsManager.spec.ts b/packages/backend/src/managers/modelsManager.spec.ts index b5c2679b7..b7a7ade90 100644 --- a/packages/backend/src/managers/modelsManager.spec.ts +++ b/packages/backend/src/managers/modelsManager.spec.ts @@ -520,7 +520,9 @@ describe('downloadModel', () => { mocks.onEventDownloadMock.mockImplementation(listener => { listener({ + id: 'id', status: 'completed', + duration: 1000, }); }); diff --git a/packages/backend/src/managers/modelsManager.ts b/packages/backend/src/managers/modelsManager.ts index 9ac95c696..b41671063 100644 --- a/packages/backend/src/managers/modelsManager.ts +++ b/packages/backend/src/managers/modelsManager.ts @@ -24,9 +24,12 @@ import { Messages } from '@shared/Messages'; import type { CatalogManager } from './catalogManager'; import type { ModelInfo } from '@shared/src/models/IModelInfo'; import * as podmanDesktopApi from '@podman-desktop/api'; -import { Downloader, type DownloadEvent, isCompletionEvent, isProgressEvent } from '../utils/downloader'; +import { Downloader } from '../utils/downloader'; import type { TaskRegistry } from '../registries/TaskRegistry'; import type { Task } from '@shared/src/models/ITask'; +import type { BaseEvent } from '../models/baseEvent'; +import { isCompletionEvent, isProgressEvent } from '../models/baseEvent'; +import { Uploader } from '../utils/uploader'; export class ModelsManager implements Disposable { #modelsDir: string; @@ -215,12 +218,18 @@ export class ModelsManager implements Disposable { }); } - private onDownloadEvent(event: DownloadEvent): void { + private onDownloadUploadEvent(event: BaseEvent, action: 'download' | 'upload'): void { + let taskLabel = 'model-pulling'; + let eventName = 'model.download'; + if (action === 'upload') { + taskLabel = 'model-uploading'; + eventName = 'model.upload'; + } // Always use the task registry as source of truth for tasks - const tasks = this.taskRegistry.getTasksByLabels({ 'model-pulling': event.id }); + const tasks = this.taskRegistry.getTasksByLabels({ [taskLabel]: event.id }); if (tasks.length === 0) { // tasks might have been cleared but still an error. - console.error('received download event but no task is associated.'); + console.error(`received ${action} event but no task is associated.`); return; } @@ -236,9 +245,9 @@ export class ModelsManager implements Disposable { task.error = event.message; // telemetry usage - this.telemetry.logError('model.download', { + this.telemetry.logError(eventName, { 'model.id': event.id, - message: 'error downloading model', + message: `error ${action}ing model`, error: event.message, durationSeconds: event.duration, }); @@ -247,7 +256,7 @@ export class ModelsManager implements Disposable { task.progress = 100; // telemetry usage - this.telemetry.logUsage('model.download', { 'model.id': event.id, durationSeconds: event.duration }); + this.telemetry.logUsage(eventName, { 'model.id': event.id, durationSeconds: event.duration }); } } this.taskRegistry.updateTask(task); // update task @@ -294,10 +303,27 @@ export class ModelsManager implements Disposable { const downloader = this.createDownloader(model); // Capture downloader events - downloader.onEvent(this.onDownloadEvent.bind(this)); + downloader.onEvent(event => this.onDownloadUploadEvent(event, 'download'), this); // perform download await downloader.perform(model.id); return downloader.getTarget(); } + + async uploadModelToPodmanMachine( + model: ModelInfo, + localModelPath: string, + labels?: { [key: string]: string }, + ): Promise { + this.taskRegistry.createTask(`Uploading model ${model.name}`, 'loading', { + ...labels, + 'model-uploading': model.id, + }); + + const uploader = new Uploader(localModelPath); + uploader.onEvent(event => this.onDownloadUploadEvent(event, 'upload'), this); + + // perform download + return uploader.perform(model.id); + } } diff --git a/packages/backend/src/managers/playground.spec.ts b/packages/backend/src/managers/playground.spec.ts index 44d623f56..33dd780ce 100644 --- a/packages/backend/src/managers/playground.spec.ts +++ b/packages/backend/src/managers/playground.spec.ts @@ -39,6 +39,7 @@ const mocks = vi.hoisted(() => ({ listContainers: vi.fn(), logUsage: vi.fn(), logError: vi.fn(), + getFirstRunningPodmanConnectionMock: vi.fn(), })); vi.mock('@podman-desktop/api', async () => { @@ -55,6 +56,12 @@ vi.mock('@podman-desktop/api', async () => { }; }); +vi.mock('../utils/podman', () => { + return { + getFirstRunningPodmanConnection: mocks.getFirstRunningPodmanConnectionMock, + }; +}); + const containerRegistryMock = { subscribe: mocks.containerRegistrySubscribeMock, } as unknown as ContainerRegistry; @@ -103,14 +110,12 @@ test('startPlayground should fail if no provider', async () => { test('startPlayground should download image if not present then create container', async () => { mocks.postMessage.mockResolvedValue(undefined); - mocks.getContainerConnections.mockReturnValue([ - { - connection: { - type: 'podman', - status: () => 'started', - }, + mocks.getFirstRunningPodmanConnectionMock.mockReturnValue({ + connection: { + type: 'podman', + status: () => 'started', }, - ]); + }); vi.spyOn(manager, 'selectImage') .mockResolvedValueOnce(undefined) .mockResolvedValueOnce({ @@ -162,14 +167,12 @@ test('stopPlayground should fail if no playground is running', async () => { test('stopPlayground should stop a started playground', async () => { mocks.postMessage.mockResolvedValue(undefined); - mocks.getContainerConnections.mockReturnValue([ - { - connection: { - type: 'podman', - status: () => 'started', - }, + mocks.getFirstRunningPodmanConnectionMock.mockReturnValue({ + connection: { + type: 'podman', + status: () => 'started', }, - ]); + }); vi.spyOn(manager, 'selectImage').mockResolvedValue({ Id: 'image1', engineId: 'engine1', diff --git a/packages/backend/src/managers/playground.ts b/packages/backend/src/managers/playground.ts index ed66ac766..d3a218e3a 100644 --- a/packages/backend/src/managers/playground.ts +++ b/packages/backend/src/managers/playground.ts @@ -16,14 +16,7 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ -import { - containerEngine, - type Webview, - type ImageInfo, - type ProviderContainerConnection, - provider, - type TelemetryLogger, -} from '@podman-desktop/api'; +import { containerEngine, type Webview, type ImageInfo, type TelemetryLogger } from '@podman-desktop/api'; import path from 'node:path'; import { getFreePort } from '../utils/ports'; @@ -35,6 +28,7 @@ import type { PodmanConnection } from './podmanConnection'; import OpenAI from 'openai'; import { DISABLE_SELINUX_LABEL_SECURITY_OPTION, getDurationSecondsSince, timeout } from '../utils/utils'; import type { ModelInfo } from '@shared/src/models/IModelInfo'; +import { getFirstRunningPodmanConnection } from '../utils/podman'; export const LABEL_MODEL_ID = 'ai-studio-model-id'; export const LABEL_MODEL_PORT = 'ai-studio-model-port'; @@ -45,14 +39,6 @@ const PLAYGROUND_IMAGE = 'quay.io/bootsy/playground:v0'; const STARTING_TIME_MAX = 3600 * 1000; -function findFirstProvider(): ProviderContainerConnection | undefined { - const engines = provider - .getContainerConnections() - .filter(connection => connection.connection.type === 'podman') - .filter(connection => connection.connection.status() === 'started'); - return engines.length > 0 ? engines[0] : undefined; -} - export class PlayGroundManager { private queryIdCounter = 0; @@ -172,7 +158,7 @@ export class PlayGroundManager { this.setPlaygroundStatus(modelId, 'starting'); - const connection = findFirstProvider(); + const connection = getFirstRunningPodmanConnection(); if (!connection) { const error = 'Unable to find an engine to start playground'; this.setPlaygroundError(modelId, error); diff --git a/packages/backend/src/managers/podmanConnection.spec.ts b/packages/backend/src/managers/podmanConnection.spec.ts index 6b137dd41..015e4db31 100644 --- a/packages/backend/src/managers/podmanConnection.spec.ts +++ b/packages/backend/src/managers/podmanConnection.spec.ts @@ -21,7 +21,7 @@ import { PodmanConnection } from './podmanConnection'; import type { RegisterContainerConnectionEvent, UpdateContainerConnectionEvent } from '@podman-desktop/api'; const mocks = vi.hoisted(() => ({ - getContainerConnections: vi.fn(), + getFirstRunningPodmanConnectionMock: vi.fn(), onDidRegisterContainerConnection: vi.fn(), onDidUpdateContainerConnection: vi.fn(), })); @@ -30,23 +30,26 @@ vi.mock('@podman-desktop/api', async () => { return { provider: { onDidRegisterContainerConnection: mocks.onDidRegisterContainerConnection, - getContainerConnections: mocks.getContainerConnections, onDidUpdateContainerConnection: mocks.onDidUpdateContainerConnection, }, }; }); -test('startupSubscribe should execute immediately if provider already registered', () => { +vi.mock('../utils/podman', () => { + return { + getFirstRunningPodmanConnection: mocks.getFirstRunningPodmanConnectionMock, + }; +}); + +test('startupSubscribe should execute immediately if provider already registered', async () => { const manager = new PodmanConnection(); // one provider is already registered - mocks.getContainerConnections.mockReturnValue([ - { - connection: { - type: 'podman', - status: () => 'started', - }, + mocks.getFirstRunningPodmanConnectionMock.mockReturnValue({ + connection: { + type: 'podman', + status: () => 'started', }, - ]); + }); mocks.onDidRegisterContainerConnection.mockReturnValue({ dispose: vi.fn, }); @@ -61,7 +64,7 @@ test('startupSubscribe should execute when provider is registered', async () => const manager = new PodmanConnection(); // no provider is already registered - mocks.getContainerConnections.mockReturnValue([]); + mocks.getFirstRunningPodmanConnectionMock.mockReturnValue(undefined); mocks.onDidRegisterContainerConnection.mockImplementation((f: (e: RegisterContainerConnectionEvent) => void) => { setTimeout(() => { f({ diff --git a/packages/backend/src/managers/podmanConnection.ts b/packages/backend/src/managers/podmanConnection.ts index 09a8d6b42..8b6be0641 100644 --- a/packages/backend/src/managers/podmanConnection.ts +++ b/packages/backend/src/managers/podmanConnection.ts @@ -24,6 +24,7 @@ import { type PodInfo, type Disposable, } from '@podman-desktop/api'; +import { getFirstRunningPodmanConnection } from '../utils/podman'; export type startupHandle = () => void; export type machineStartHandle = () => void; @@ -72,11 +73,8 @@ export class PodmanConnection implements Disposable { }); // In case at least one extension has already registered, we get one started podman provider - const engines = provider - .getContainerConnections() - .filter(connection => connection.connection.type === 'podman') - .filter(connection => connection.connection.status() === 'started'); - if (engines.length > 0) { + const engine = getFirstRunningPodmanConnection(); + if (engine) { disposable.dispose(); this.#firstFound = true; } diff --git a/packages/backend/src/models/baseEvent.ts b/packages/backend/src/models/baseEvent.ts new file mode 100644 index 000000000..16c4200ab --- /dev/null +++ b/packages/backend/src/models/baseEvent.ts @@ -0,0 +1,49 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +export interface BaseEvent { + id: string; + status: 'error' | 'completed' | 'progress' | 'canceled'; + message?: string; +} + +export interface CompletionEvent extends BaseEvent { + status: 'completed' | 'error' | 'canceled'; + duration: number; +} + +export interface ProgressEvent extends BaseEvent { + status: 'progress'; + value: number; +} + +export const isCompletionEvent = (value: unknown): value is CompletionEvent => { + return ( + !!value && + typeof value === 'object' && + 'status' in value && + typeof value['status'] === 'string' && + ['canceled', 'completed', 'error'].includes(value['status']) + ); +}; + +export const isProgressEvent = (value: unknown): value is ProgressEvent => { + return ( + !!value && typeof value === 'object' && 'status' in value && value['status'] === 'progress' && 'value' in value + ); +}; diff --git a/packages/backend/src/utils/WSLUploader.spec.ts b/packages/backend/src/utils/WSLUploader.spec.ts new file mode 100644 index 000000000..d3d79a8a6 --- /dev/null +++ b/packages/backend/src/utils/WSLUploader.spec.ts @@ -0,0 +1,118 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { expect, test, describe, vi } from 'vitest'; +import { WSLUploader } from './WSLUploader'; +import * as podmanDesktopApi from '@podman-desktop/api'; +import * as utils from './podman'; +import { beforeEach } from 'node:test'; + +const mocks = vi.hoisted(() => { + return { + execMock: vi.fn(), + }; +}); + +vi.mock('@podman-desktop/api', () => ({ + env: { + isWindows: false, + }, + process: { + exec: mocks.execMock, + }, +})); + +const wslUploader = new WSLUploader(); + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe('canUpload', () => { + test('should return false if system is not windows', () => { + vi.mocked(podmanDesktopApi.env).isWindows = false; + const result = wslUploader.canUpload(); + expect(result).toBeFalsy(); + }); + test('should return true if system is windows', () => { + vi.mocked(podmanDesktopApi.env).isWindows = true; + const result = wslUploader.canUpload(); + expect(result).toBeTruthy(); + }); +}); + +describe('upload', () => { + vi.spyOn(utils, 'getPodmanCli').mockReturnValue('podman'); + vi.spyOn(utils, 'getFirstRunningPodmanConnection').mockResolvedValue({ + connection: { + name: 'test', + status: vi.fn(), + endpoint: { + socketPath: '/endpoint.sock', + }, + type: 'podman', + }, + providerId: 'podman', + }); + test('throw if localpath is not defined', async () => { + await expect(wslUploader.upload('')).rejects.toThrowError('invalid local path'); + }); + test('copy model if not exists on podman machine', async () => { + mocks.execMock.mockRejectedValueOnce('error'); + vi.spyOn(utils, 'getFirstRunningMachineName').mockReturnValue('machine2'); + await wslUploader.upload('C:\\Users\\podman\\folder\\file'); + expect(mocks.execMock).toBeCalledWith('podman', [ + 'machine', + 'ssh', + 'machine2', + 'stat', + '/home/user/ai-studio/models/file', + ]); + expect(mocks.execMock).toBeCalledWith('podman', [ + 'machine', + 'ssh', + 'machine2', + 'mkdir', + '-p', + '/home/user/ai-studio/models/', + ]); + expect(mocks.execMock).toBeCalledWith('podman', [ + 'machine', + 'ssh', + 'machine2', + 'cp', + '/mnt/c/Users/podman/folder/file', + '/home/user/ai-studio/models/file', + ]); + mocks.execMock.mockClear(); + }); + test('do not copy model if it exists on podman machine', async () => { + mocks.execMock.mockResolvedValue(''); + vi.spyOn(utils, 'getFirstRunningMachineName').mockReturnValue('machine2'); + await wslUploader.upload('C:\\Users\\podman\\folder\\file'); + expect(mocks.execMock).toBeCalledWith('podman', [ + 'machine', + 'ssh', + 'machine2', + 'stat', + '/home/user/ai-studio/models/file', + ]); + expect(mocks.execMock).toBeCalledTimes(1); + mocks.execMock.mockClear(); + }); +}); diff --git a/packages/backend/src/utils/WSLUploader.ts b/packages/backend/src/utils/WSLUploader.ts new file mode 100644 index 000000000..5e55f9f7b --- /dev/null +++ b/packages/backend/src/utils/WSLUploader.ts @@ -0,0 +1,68 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import path from 'node:path'; +import * as podmanDesktopApi from '@podman-desktop/api'; +import { getFirstRunningMachineName, getPodmanCli } from './podman'; +import type { UploadWorker } from './uploader'; + +export class WSLUploader implements UploadWorker { + canUpload(): boolean { + return podmanDesktopApi.env.isWindows; + } + + async upload(localPath: string): Promise { + if (!localPath) { + throw new Error('invalid local path'); + } + + const driveLetter = localPath.charAt(0); + const convertToMntPath = localPath + .replace(`${driveLetter}:\\`, `/mnt/${driveLetter.toLowerCase()}/`) + .replace(/\\/g, '/'); + const remotePath = '/home/user/ai-studio/models/'; + const remoteFile = `${remotePath}${path.basename(convertToMntPath)}`; + const machineName = getFirstRunningMachineName(); + + if (!machineName) { + throw new Error('No podman machine is running'); + } + // check if model already loaded on the podman machine + let existsRemote = true; + try { + await podmanDesktopApi.process.exec(getPodmanCli(), ['machine', 'ssh', machineName, 'stat', remoteFile]); + } catch (e) { + existsRemote = false; + } + + // if not exists remotely it copies it from the local path + if (!existsRemote) { + await podmanDesktopApi.process.exec(getPodmanCli(), ['machine', 'ssh', machineName, 'mkdir', '-p', remotePath]); + await podmanDesktopApi.process.exec(getPodmanCli(), [ + 'machine', + 'ssh', + machineName, + 'cp', + convertToMntPath, + remoteFile, + ]); + } + + return remoteFile; + } +} diff --git a/packages/backend/src/utils/downloader.ts b/packages/backend/src/utils/downloader.ts index b854d464b..a0de9daf7 100644 --- a/packages/backend/src/utils/downloader.ts +++ b/packages/backend/src/utils/downloader.ts @@ -20,43 +20,11 @@ import { getDurationSecondsSince } from './utils'; import { createWriteStream, promises } from 'node:fs'; import https from 'node:https'; import { EventEmitter, type Event } from '@podman-desktop/api'; - -export interface DownloadEvent { - id: string; - status: 'error' | 'completed' | 'progress' | 'canceled'; - message?: string; -} - -export interface CompletionEvent extends DownloadEvent { - status: 'completed' | 'error' | 'canceled'; - duration: number; -} - -export interface ProgressEvent extends DownloadEvent { - status: 'progress'; - value: number; -} - -export const isCompletionEvent = (value: unknown): value is CompletionEvent => { - return ( - !!value && - typeof value === 'object' && - 'status' in value && - typeof value['status'] === 'string' && - ['canceled', 'completed', 'error'].includes(value['status']) && - 'duration' in value - ); -}; - -export const isProgressEvent = (value: unknown): value is ProgressEvent => { - return ( - !!value && typeof value === 'object' && 'status' in value && value['status'] === 'progress' && 'value' in value - ); -}; +import type { CompletionEvent, ProgressEvent, BaseEvent } from '../models/baseEvent'; export class Downloader { - private readonly _onEvent = new EventEmitter(); - readonly onEvent: Event = this._onEvent.event; + private readonly _onEvent = new EventEmitter(); + readonly onEvent: Event = this._onEvent.event; private requestedIdentifier: string; completed: boolean; diff --git a/packages/backend/src/utils/podman.spec.ts b/packages/backend/src/utils/podman.spec.ts new file mode 100644 index 000000000..57fe47328 --- /dev/null +++ b/packages/backend/src/utils/podman.spec.ts @@ -0,0 +1,206 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { expect, test, describe, vi } from 'vitest'; +import * as podmanDesktopApi from '@podman-desktop/api'; +import * as utils from '../utils/podman'; +import { beforeEach } from 'node:test'; + +const mocks = vi.hoisted(() => { + return { + getConfigurationMock: vi.fn(), + getContainerConnectionsMock: vi.fn(), + }; +}); + +const config: podmanDesktopApi.Configuration = { + get: mocks.getConfigurationMock, + has: () => true, + update: () => Promise.resolve(), +}; + +vi.mock('@podman-desktop/api', () => ({ + env: { + isWindows: false, + }, + configuration: { + getConfiguration: () => config, + }, + provider: { + getContainerConnections: mocks.getContainerConnectionsMock, + }, +})); + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe('getPodmanCli', () => { + test('should return custom binary path if setting is set', () => { + mocks.getConfigurationMock.mockReturnValue('binary'); + const result = utils.getPodmanCli(); + expect(result).equals('binary'); + }); + test('should return exe file if on windows', () => { + vi.mocked(podmanDesktopApi.env).isWindows = true; + mocks.getConfigurationMock.mockReturnValue(undefined); + const result = utils.getPodmanCli(); + expect(result).equals('podman.exe'); + }); + test('should return podman file if not on windows', () => { + vi.mocked(podmanDesktopApi.env).isWindows = false; + mocks.getConfigurationMock.mockReturnValue(undefined); + const result = utils.getPodmanCli(); + expect(result).equals('podman'); + }); +}); + +describe('getFirstRunningMachineName', () => { + test('return machine name if connection name does contain default Podman Machine name', () => { + mocks.getContainerConnectionsMock.mockReturnValue([ + { + connection: { + name: 'Podman Machine', + status: () => 'started', + endpoint: { + socketPath: '/endpoint.sock', + }, + type: 'podman', + }, + providerId: 'podman', + }, + ]); + const machineName = utils.getFirstRunningMachineName(); + expect(machineName).equals('podman-machine-default'); + }); + test('return machine name if connection name does contain custom Podman Machine name', () => { + mocks.getContainerConnectionsMock.mockReturnValue([ + { + connection: { + name: 'Podman Machine test', + status: () => 'started', + endpoint: { + socketPath: '/endpoint.sock', + }, + type: 'podman', + }, + providerId: 'podman', + }, + ]); + const machineName = utils.getFirstRunningMachineName(); + expect(machineName).equals('podman-machine-test'); + }); + test('return machine name if connection name does not contain Podman Machine', () => { + mocks.getContainerConnectionsMock.mockReturnValue([ + { + connection: { + name: 'test', + status: () => 'started', + endpoint: { + socketPath: '/endpoint.sock', + }, + type: 'podman', + }, + providerId: 'podman', + }, + ]); + const machineName = utils.getFirstRunningMachineName(); + expect(machineName).equals('test'); + }); + test('return undefined if there is no running connection', () => { + mocks.getContainerConnectionsMock.mockReturnValue([ + { + connection: { + name: 'machine', + status: () => 'stopped', + endpoint: { + socketPath: '/endpoint.sock', + }, + type: 'podman', + }, + providerId: 'podman', + }, + ]); + const machineName = utils.getFirstRunningMachineName(); + expect(machineName).toBeUndefined(); + }); +}); + +describe('getFirstRunningPodmanConnection', () => { + test('should return undefined if failing at retrieving connection', async () => { + mocks.getConfigurationMock.mockRejectedValue('error'); + const result = utils.getFirstRunningPodmanConnection(); + expect(result).toBeUndefined(); + }); + test('should return undefined if default podman machine is not running', async () => { + mocks.getContainerConnectionsMock.mockReturnValue([ + { + connection: { + name: 'machine', + status: () => 'stopped', + endpoint: { + socketPath: '/endpoint.sock', + }, + type: 'podman', + }, + providerId: 'podman', + }, + { + connection: { + name: 'machine2', + status: () => 'stopped', + endpoint: { + socketPath: '/endpoint.sock', + }, + type: 'podman', + }, + providerId: 'podman2', + }, + ]); + const result = utils.getFirstRunningPodmanConnection(); + expect(result).toBeUndefined(); + }); + test('should return default running podman connection', async () => { + mocks.getContainerConnectionsMock.mockReturnValue([ + { + connection: { + name: 'machine', + status: () => 'stopped', + endpoint: { + socketPath: '/endpoint.sock', + }, + type: 'podman', + }, + providerId: 'podman', + }, + { + connection: { + name: 'machine2', + status: () => 'started', + endpoint: { + socketPath: '/endpoint.sock', + }, + type: 'podman', + }, + providerId: 'podman2', + }, + ]); + const result = utils.getFirstRunningPodmanConnection(); + expect(result.connection.name).equal('machine2'); + }); +}); diff --git a/packages/backend/src/utils/podman.ts b/packages/backend/src/utils/podman.ts new file mode 100644 index 000000000..cfa6dd70a --- /dev/null +++ b/packages/backend/src/utils/podman.ts @@ -0,0 +1,88 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import type { ProviderContainerConnection } from '@podman-desktop/api'; +import { configuration, env, provider } from '@podman-desktop/api'; + +export type MachineJSON = { + Name: string; + CPUs: number; + Memory: string; + DiskSize: string; + Running: boolean; + Starting: boolean; + Default: boolean; + UserModeNetworking?: boolean; +}; + +export function getPodmanCli(): string { + // If we have a custom binary path regardless if we are running Windows or not + const customBinaryPath = getCustomBinaryPath(); + if (customBinaryPath) { + return customBinaryPath; + } + + if (env.isWindows) { + return 'podman.exe'; + } + return 'podman'; +} + +// Get the Podman binary path from configuration podman.binary.path +// return string or undefined +export function getCustomBinaryPath(): string | undefined { + return configuration.getConfiguration('podman').get('binary.path'); +} + +export function getFirstRunningMachineName(): string | undefined { + // the name of the podman connection is the name of the podman machine updated to make it more user friendly, + // so to retrieve the real machine name we need to revert the process + + // podman-machine-default -> Podman Machine + // podman-machine-{name} -> Podman Machine {name} + // {name} -> {name} + try { + const runningConnection = getFirstRunningPodmanConnection(); + const runningConnectionName = runningConnection.connection.name; + if (runningConnectionName.startsWith('Podman Machine')) { + const machineName = runningConnectionName.replace(/Podman Machine\s*/, 'podman-machine-'); + if (machineName.endsWith('-')) { + return `${machineName}default`; + } + return machineName; + } else { + return runningConnectionName; + } + } catch (e) { + console.log(e); + } + + return undefined; +} + +export function getFirstRunningPodmanConnection(): ProviderContainerConnection | undefined { + let engine: ProviderContainerConnection; + try { + engine = provider + .getContainerConnections() + .filter(connection => connection.connection.type === 'podman') + .find(connection => connection.connection.status() === 'started'); + } catch (e) { + console.log(e); + } + return engine; +} diff --git a/packages/backend/src/utils/uploader.spec.ts b/packages/backend/src/utils/uploader.spec.ts new file mode 100644 index 000000000..fe0c5517e --- /dev/null +++ b/packages/backend/src/utils/uploader.spec.ts @@ -0,0 +1,64 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { expect, test, describe, vi } from 'vitest'; +import { WSLUploader } from './WSLUploader'; +import * as podmanDesktopApi from '@podman-desktop/api'; +import { beforeEach } from 'node:test'; +import { Uploader } from './uploader'; + +const mocks = vi.hoisted(() => { + return { + execMock: vi.fn(), + }; +}); + +vi.mock('@podman-desktop/api', async () => { + return { + env: { + isWindows: false, + }, + process: { + exec: mocks.execMock, + }, + EventEmitter: vi.fn().mockImplementation(() => { + return { + fire: vi.fn(), + }; + }), + }; +}); +const uploader = new Uploader('localpath'); + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe('perform', () => { + test('should return localModelPath if no workers for current system', async () => { + vi.mocked(podmanDesktopApi.env).isWindows = false; + const result = await uploader.perform('id'); + expect(result).toBe('localpath'); + }); + test('should return remote path if there is a worker for current system', async () => { + vi.spyOn(WSLUploader.prototype, 'upload').mockResolvedValue('remote'); + vi.mocked(podmanDesktopApi.env).isWindows = true; + const result = await uploader.perform('id'); + expect(result).toBe('remote'); + }); +}); diff --git a/packages/backend/src/utils/uploader.ts b/packages/backend/src/utils/uploader.ts new file mode 100644 index 000000000..81c93a1ef --- /dev/null +++ b/packages/backend/src/utils/uploader.ts @@ -0,0 +1,86 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { EventEmitter, type Event } from '@podman-desktop/api'; +import { WSLUploader } from './WSLUploader'; +import { getDurationSecondsSince } from './utils'; +import type { CompletionEvent, BaseEvent } from '../models/baseEvent'; + +export interface UploadWorker { + canUpload: () => boolean; + upload: (path: string) => Promise; +} + +export class Uploader { + readonly #_onEvent = new EventEmitter(); + readonly onEvent: Event = this.#_onEvent.event; + readonly #workers: UploadWorker[] = []; + + constructor( + private localModelPath: string, + private abortSignal?: AbortSignal, + ) { + this.#workers = [new WSLUploader()]; + } + + async perform(id: string): Promise { + const workers = this.#workers.filter(w => w.canUpload()); + let modelPath = this.localModelPath; + try { + if (workers && workers.length > 1) { + throw new Error('too many uploaders registered for this system'); + } + const worker = workers?.[0]; + if (worker) { + const startTime = performance.now(); + modelPath = await worker.upload(this.localModelPath); + const durationSeconds = getDurationSecondsSince(startTime); + this.#_onEvent.fire({ + id, + status: 'completed', + message: `Duration ${durationSeconds}s.`, + duration: durationSeconds, + } as CompletionEvent); + return modelPath; + } + } catch (err) { + if (!this.abortSignal?.aborted) { + this.#_onEvent.fire({ + id, + status: 'error', + message: `Something went wrong: ${String(err)}.`, + }); + } else { + this.#_onEvent.fire({ + id, + status: 'canceled', + message: `Request cancelled: ${String(err)}.`, + }); + } + throw new Error(`Unable to upload model. Error: ${String(err)}`); + } + + this.#_onEvent.fire({ + id, + status: 'completed', + message: `Use local model`, + } as CompletionEvent); + + return modelPath; + } +}