diff --git a/packages/backend/src/managers/applicationManager.spec.ts b/packages/backend/src/managers/applicationManager.spec.ts index 1d8a55f57..4e4015cd9 100644 --- a/packages/backend/src/managers/applicationManager.spec.ts +++ b/packages/backend/src/managers/applicationManager.spec.ts @@ -50,10 +50,8 @@ const mocks = vi.hoisted(() => { getImageInspectMock: vi.fn(), createPodMock: vi.fn(), createContainerMock: vi.fn(), - replicatePodmanContainerMock: vi.fn(), startContainerMock: vi.fn(), startPod: vi.fn(), - deleteContainerMock: vi.fn(), inspectContainerMock: vi.fn(), logUsageMock: vi.fn(), logErrorMock: vi.fn(), @@ -107,10 +105,8 @@ vi.mock('@podman-desktop/api', () => ({ getImageInspect: mocks.getImageInspectMock, createPod: mocks.createPodMock, createContainer: mocks.createContainerMock, - replicatePodmanContainer: mocks.replicatePodmanContainerMock, startContainer: mocks.startContainerMock, startPod: mocks.startPod, - deleteContainer: mocks.deleteContainerMock, inspectContainer: mocks.inspectContainerMock, pullImage: mocks.pullImageMock, stopContainer: mocks.stopContainerMock, @@ -259,6 +255,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', @@ -321,6 +318,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', @@ -349,6 +347,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', @@ -1007,7 +1006,7 @@ describe('createAndAddContainersToPod', () => { modelService: false, ports: ['8080'], }; - test('check that after the creation and copy inside the pod, the container outside the pod is actually deleted', async () => { + test('check that container is created inside the pod', async () => { mocks.createContainerMock.mockResolvedValue({ id: 'container-1', }); @@ -1015,27 +1014,15 @@ describe('createAndAddContainersToPod', () => { await manager.createAndAddContainersToPod(pod, [imageInfo1], 'path'); expect(mocks.createContainerMock).toBeCalledWith('engine', { Image: 'id', + name: 'name', Detach: true, HostConfig: { AutoRemove: true, }, Env: [], start: false, + pod: pod.Id, }); - expect(mocks.replicatePodmanContainerMock).toBeCalledWith( - { - id: 'container-1', - engineId: 'engine', - }, - { - engineId: 'engine', - }, - { - pod: 'id', - name: 'name', - }, - ); - expect(mocks.deleteContainerMock).toBeCalledWith('engine', 'container-1'); }); }); diff --git a/packages/backend/src/managers/applicationManager.ts b/packages/backend/src/managers/applicationManager.ts index 085d4861a..248b5accb 100644 --- a/packages/backend/src/managers/applicationManager.ts +++ b/packages/backend/src/managers/applicationManager.ts @@ -121,7 +121,13 @@ export class ApplicationManager { const configAndFilteredContainers = this.getConfigAndFilterContainers(recipe.config, localFolder); // get model by downloading it or retrieving locally - const modelPath = await this.modelsManager.downloadModel(model, { + let modelPath = await this.modelsManager.downloadModel(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, }); @@ -258,6 +264,7 @@ export class ApplicationManager { Target: `/${modelName}`, Source: modelPath, Type: 'bind', + Mode: 'Z', }, ], }; @@ -273,35 +280,21 @@ export class ApplicationManager { envs = [`MODEL_ENDPOINT=${endPoint}`]; } } - const createdContainer = await containerEngine.createContainer(podInfo.engineId, { + const podifiedName = this.getRandomName(`${image.appName}-podified`); + await containerEngine.createContainer(podInfo.engineId, { Image: image.id, + name: podifiedName, Detach: true, HostConfig: hostConfig, Env: envs, start: false, + pod: podInfo.Id, + }); + containers.push({ + name: podifiedName, + modelService: image.modelService, + ports: image.ports, }); - - // now, for each container, put it in the pod - if (createdContainer) { - const podifiedName = this.getRandomName(`${image.appName}-podified`); - await containerEngine.replicatePodmanContainer( - { - id: createdContainer.id, - engineId: podInfo.engineId, - }, - { engineId: podInfo.engineId }, - { pod: podInfo.Id, name: podifiedName }, - ); - containers.push({ - name: podifiedName, - modelService: image.modelService, - ports: image.ports, - }); - // remove the external container - await containerEngine.deleteContainer(podInfo.engineId, createdContainer.id); - } else { - throw new Error(`failed at creating container for image ${image.id}`); - } }), ); return containers; diff --git a/packages/backend/src/managers/modelsManager.ts b/packages/backend/src/managers/modelsManager.ts index 1b9fe99e5..840557352 100644 --- a/packages/backend/src/managers/modelsManager.ts +++ b/packages/backend/src/managers/modelsManager.ts @@ -24,9 +24,12 @@ import { MSG_NEW_MODELS_STATE } 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 { ProgressiveEvent } from '../utils/progressiveEvent'; +import { isCompletionProgressiveEvent, isProgressProgressiveEvent } from '../utils/progressiveEvent'; +import { Uploader } from '../models/uploader'; export class ModelsManager implements Disposable { #modelsDir: string; @@ -201,11 +204,11 @@ export class ModelsManager implements Disposable { const downloader = new Downloader(model.url, target); // Capture downloader events - downloader.onEvent((event: DownloadEvent) => { - if (isProgressEvent(event)) { + downloader.onEvent((event: ProgressiveEvent) => { + if (isProgressProgressiveEvent(event)) { task.state = 'loading'; task.progress = event.value; - } else if (isCompletionEvent(event)) { + } else if (isCompletionProgressiveEvent(event)) { // status error or canceled if (event.status === 'error' || event.status === 'canceled') { task.state = 'error'; @@ -235,4 +238,49 @@ export class ModelsManager implements Disposable { await downloader.perform(); return target; } + + async uploadModelToPodmanMachine( + model: ModelInfo, + localModelPath: string, + labels?: { [key: string]: string }, + ): Promise { + const task: Task = this.taskRegistry.createTask(`Uploading model ${model.name}`, 'loading', { + ...labels, + 'model-uploading': model.id, + }); + + const uploader = new Uploader(localModelPath); + uploader.onEvent((event: ProgressiveEvent) => { + if (isProgressProgressiveEvent(event)) { + task.state = 'loading'; + task.progress = event.value; + } else if (isCompletionProgressiveEvent(event)) { + // status error or canceled + if (event.status === 'error' || event.status === 'canceled') { + task.state = 'error'; + task.progress = undefined; + task.error = event.message; + + // telemetry usage + this.telemetry.logError('model.upload', { + 'model.id': model.id, + message: 'error uploading model', + error: event.message, + durationSeconds: event.duration, + }); + } else { + task.state = 'success'; + task.progress = 100; + + // telemetry usage + this.telemetry.logUsage('model.upload', { 'model.id': model.id, durationSeconds: event.duration }); + } + } + + this.taskRegistry.updateTask(task); // update task + }); + + // perform download + return await uploader.perform(); + } } diff --git a/packages/backend/src/managers/playground.spec.ts b/packages/backend/src/managers/playground.spec.ts index b954fac0a..d3c040f08 100644 --- a/packages/backend/src/managers/playground.spec.ts +++ b/packages/backend/src/managers/playground.spec.ts @@ -35,6 +35,7 @@ const mocks = vi.hoisted(() => ({ listContainers: vi.fn(), logUsage: vi.fn(), logError: vi.fn(), + getFirstRunningPodmanConnectionMock: vi.fn(), })); vi.mock('@podman-desktop/api', async () => { @@ -51,6 +52,12 @@ vi.mock('@podman-desktop/api', async () => { }; }); +vi.mock('../utils/podman', () => { + return { + getFirstRunningPodmanConnection: mocks.getFirstRunningPodmanConnectionMock, + }; +}); + const containerRegistryMock = { subscribe: mocks.containerRegistrySubscribeMock, } as unknown as ContainerRegistry; @@ -99,14 +106,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({ @@ -157,14 +162,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 f96e27404..9cbd280aa 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 { 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'; @@ -44,14 +38,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; @@ -171,7 +157,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/QemuUploader.spec.ts b/packages/backend/src/models/QemuUploader.spec.ts new file mode 100644 index 000000000..1ee39e268 --- /dev/null +++ b/packages/backend/src/models/QemuUploader.spec.ts @@ -0,0 +1,111 @@ +/********************************************************************** + * 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 utils from '../utils/podman'; +import { beforeEach } from 'node:test'; +import { QemuUploader } from './QemuUploader'; + +const mocks = vi.hoisted(() => { + return { + execMock: vi.fn(), + }; +}); + +vi.mock('@podman-desktop/api', () => ({ + env: { + isWindows: false, + }, + process: { + exec: mocks.execMock, + }, +})); + +const qemuUploader = new QemuUploader(); + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe('canUpload', () => { + test('should return false if system is not qemu', async () => { + const machine: utils.MachineJSON = { + Name: 'machine', + CPUs: 2, + Memory: '2000', + DiskSize: '100', + Running: true, + Starting: false, + Default: true, + VMType: 'WSL', + }; + vi.spyOn(utils, 'getFirstRunningMachine').mockResolvedValue(machine); + const result = await qemuUploader.canUpload(); + expect(result).toBeFalsy(); + }); + test('should return true if system is qemu', async () => { + const machine: utils.MachineJSON = { + Name: 'machine', + CPUs: 2, + Memory: '2000', + DiskSize: '100', + Running: true, + Starting: false, + Default: true, + VMType: 'qemu', + }; + vi.spyOn(utils, 'getFirstRunningMachine').mockResolvedValue(machine); + const result = await qemuUploader.canUpload(); + expect(result).toBeTruthy(); + }); +}); + +describe('upload', () => { + const machine: utils.MachineJSON = { + Name: 'machine', + CPUs: 2, + Memory: '2000', + DiskSize: '100', + Running: true, + Starting: false, + Default: true, + }; + vi.spyOn(utils, 'getPodmanCli').mockReturnValue('podman'); + vi.spyOn(utils, 'getFirstRunningMachine').mockResolvedValue(machine); + test('throw if localpath is not defined', async () => { + await expect(qemuUploader.upload('')).rejects.toThrowError('invalid local path'); + }); + test('copy model if not exists on podman machine', async () => { + mocks.execMock.mockRejectedValueOnce('error'); + await qemuUploader.upload('/home/user/folder/file'); + expect(mocks.execMock).toBeCalledWith('podman', ['machine', 'ssh', 'machine', 'stat', '/var/home/core/file']); + }); + test('do not copy model if it exists on podman machine', async () => { + mocks.execMock.mockResolvedValue(''); + await qemuUploader.upload('/home/user/folder/file'); + expect(mocks.execMock).toBeCalledWith('podman', ['machine', 'ssh', 'machine', 'stat', '/var/home/core/file']); + expect(mocks.execMock).toBeCalledWith('podman', [ + 'machine', + 'ssh', + 'machine', + 'cp', + '/home/user/folder/file', + '/var/home/core/file', + ]); + }); +}); diff --git a/packages/backend/src/models/QemuUploader.ts b/packages/backend/src/models/QemuUploader.ts new file mode 100644 index 000000000..20b33eaa1 --- /dev/null +++ b/packages/backend/src/models/QemuUploader.ts @@ -0,0 +1,59 @@ +/********************************************************************** + * 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 * as podmanDesktopApi from '@podman-desktop/api'; +import { getFirstRunningMachine, getPodmanCli } from '../utils/podman'; +import type { UploadWorker } from './uploader'; +import path from 'node:path'; + +export class QemuUploader implements UploadWorker { + async canUpload(): Promise { + const machine = await getFirstRunningMachine(); + return machine?.VMType === 'qemu'; + } + + async upload(localPath: string): Promise { + if (!localPath) { + throw new Error('invalid local path'); + } + + const machine = await getFirstRunningMachine(); + const remotePath = `/var/home/core/${path.basename(localPath)}`; + // check if model already loaded on the podman machine + let existsRemote = true; + try { + await podmanDesktopApi.process.exec(getPodmanCli(), ['machine', 'ssh', machine.Name, 'stat', remotePath]); + } catch (e) { + existsRemote = false; + } + + // if not exists remotely it copies it from the local path + if (!existsRemote) { + await podmanDesktopApi.process.exec(getPodmanCli(), [ + 'machine', + 'ssh', + machine.Name, + 'cp', + localPath, + remotePath, + ]); + } + + return remotePath; + } +} diff --git a/packages/backend/src/models/WSLUploader.spec.ts b/packages/backend/src/models/WSLUploader.spec.ts new file mode 100644 index 000000000..c1cb5549a --- /dev/null +++ b/packages/backend/src/models/WSLUploader.spec.ts @@ -0,0 +1,104 @@ +/********************************************************************** + * 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 '../utils/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', async () => { + vi.mocked(podmanDesktopApi.env).isWindows = false; + const result = await wslUploader.canUpload(); + expect(result).toBeFalsy(); + }); + test('should return true if system is windows', async () => { + vi.mocked(podmanDesktopApi.env).isWindows = true; + const result = await wslUploader.canUpload(); + expect(result).toBeTruthy(); + }); +}); + +describe('upload', () => { + const machine2: utils.MachineJSON = { + Name: 'machine2', + CPUs: 2, + Memory: '2000', + DiskSize: '100', + Running: true, + Starting: false, + Default: true, + }; + 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, 'getFirstRunningMachine').mockResolvedValue(machine2); + await wslUploader.upload('C:\\Users\\podman\\folder\\file'); + expect(mocks.execMock).toBeCalledWith('podman', ['machine', 'ssh', 'machine2', 'stat', '/home/user/file']); + }); + test('do not copy model if it exists on podman machine', async () => { + mocks.execMock.mockResolvedValue(''); + vi.spyOn(utils, 'getFirstRunningMachine').mockResolvedValue(machine2); + await wslUploader.upload('C:\\Users\\podman\\folder\\file'); + expect(mocks.execMock).toBeCalledWith('podman', ['machine', 'ssh', 'machine2', 'stat', '/home/user/file']); + expect(mocks.execMock).toBeCalledWith('podman', [ + 'machine', + 'ssh', + 'machine2', + 'cp', + '/mnt/c/Users/podman/folder/file', + '/home/user/file', + ]); + }); +}); diff --git a/packages/backend/src/models/WSLUploader.ts b/packages/backend/src/models/WSLUploader.ts new file mode 100644 index 000000000..3bfac5ce8 --- /dev/null +++ b/packages/backend/src/models/WSLUploader.ts @@ -0,0 +1,62 @@ +/********************************************************************** + * 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 { getFirstRunningMachine, getPodmanCli } from '../utils/podman'; +import type { UploadWorker } from './uploader'; + +export class WSLUploader implements UploadWorker { + async canUpload(): Promise { + 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/${path.basename(convertToMntPath)}`; + const machine = await getFirstRunningMachine(); + // check if model already loaded on the podman machine + let existsRemote = true; + try { + await podmanDesktopApi.process.exec(getPodmanCli(), ['machine', 'ssh', machine.Name, 'stat', remotePath]); + } catch (e) { + existsRemote = false; + } + + // if not exists remotely it copies it from the local path + if (!existsRemote) { + await podmanDesktopApi.process.exec(getPodmanCli(), [ + 'machine', + 'ssh', + machine.Name, + 'cp', + convertToMntPath, + remotePath, + ]); + } + + return remotePath; + } +} diff --git a/packages/backend/src/models/uploader.spec.ts b/packages/backend/src/models/uploader.spec.ts new file mode 100644 index 000000000..657b7c79f --- /dev/null +++ b/packages/backend/src/models/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(); + 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(); + expect(result).toBe('remote'); + }); +}); diff --git a/packages/backend/src/models/uploader.ts b/packages/backend/src/models/uploader.ts new file mode 100644 index 000000000..0904824af --- /dev/null +++ b/packages/backend/src/models/uploader.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 { EventEmitter, type Event } from '@podman-desktop/api'; +import { WSLUploader } from './WSLUploader'; +import { getDurationSecondsSince } from '../utils/utils'; +import type { CompletionProgressiveEvent, ProgressiveEvent } from '../utils/progressiveEvent'; +import { QemuUploader } from './QemuUploader'; + +export interface UploadWorker { + canUpload: () => Promise; + 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(), new QemuUploader()]; + } + + async perform(): Promise { + const workers = []; + for (const worker of this.#workers) { + const canUpload = await worker.canUpload(); + if (canUpload) { + workers.push(worker); + } + } + 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({ + status: 'completed', + message: `Duration ${durationSeconds}s.`, + duration: durationSeconds, + } as CompletionProgressiveEvent); + return modelPath; + } + } catch (err) { + if (!this.abortSignal?.aborted) { + this.#_onEvent.fire({ + status: 'error', + message: `Something went wrong: ${String(err)}.`, + }); + } else { + this.#_onEvent.fire({ + status: 'canceled', + message: `Request cancelled: ${String(err)}.`, + }); + } + } + + this.#_onEvent.fire({ + status: 'completed', + message: `Use local model`, + } as CompletionProgressiveEvent); + + return modelPath; + } +} diff --git a/packages/backend/src/utils/downloader.ts b/packages/backend/src/utils/downloader.ts index d57495e13..0c59906b4 100644 --- a/packages/backend/src/utils/downloader.ts +++ b/packages/backend/src/utils/downloader.ts @@ -20,42 +20,11 @@ import { getDurationSecondsSince } from './utils'; import fs from 'fs'; import https from 'node:https'; import { EventEmitter, type Event } from '@podman-desktop/api'; - -export interface DownloadEvent { - 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 { CompletionProgressiveEvent, ProgressProgressiveEvent, ProgressiveEvent } from './progressiveEvent'; 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; constructor( private url: string, @@ -73,7 +42,7 @@ export class Downloader { status: 'completed', message: `Duration ${durationSeconds}s.`, duration: durationSeconds, - } as CompletionEvent); + } as CompletionProgressiveEvent); } catch (err: unknown) { if (!this.abortSignal?.aborted) { this._onEvent.fire({ @@ -126,7 +95,7 @@ export class Downloader { this._onEvent.fire({ status: 'progress', value: progressValue, - } as ProgressEvent); + } as ProgressProgressiveEvent); } // send progress in percentage (ex. 1.2%, 2.6%, 80.1%) to frontend diff --git a/packages/backend/src/utils/podman.spec.ts b/packages/backend/src/utils/podman.spec.ts new file mode 100644 index 000000000..7a5a49251 --- /dev/null +++ b/packages/backend/src/utils/podman.spec.ts @@ -0,0 +1,135 @@ +/********************************************************************** + * 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('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..909de2ed7 --- /dev/null +++ b/packages/backend/src/utils/podman.ts @@ -0,0 +1,81 @@ +/********************************************************************** + * 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, process, provider } from '@podman-desktop/api'; + +export type MachineJSON = { + Name: string; + CPUs: number; + Memory: string; + DiskSize: string; + Running: boolean; + Starting: boolean; + Default: boolean; + UserModeNetworking?: boolean; + VMType?: string; +}; + +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'); +} + +async function getJSONMachineList(): Promise { + const { stdout } = await process.exec(getPodmanCli(), ['machine', 'list', '--format', 'json']); + return stdout; +} + +export async function getFirstRunningMachine(): Promise { + try { + const machineListOutput = await getJSONMachineList(); + const machines = JSON.parse(machineListOutput) as MachineJSON[]; + return machines.find(machine => machine.Default && machine.Running); + } 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/progressiveEvent.ts b/packages/backend/src/utils/progressiveEvent.ts new file mode 100644 index 000000000..e7a60af14 --- /dev/null +++ b/packages/backend/src/utils/progressiveEvent.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 ProgressiveEvent { + status: 'error' | 'completed' | 'progress' | 'canceled'; + message?: string; +} + +export interface CompletionProgressiveEvent extends ProgressiveEvent { + status: 'completed' | 'error' | 'canceled'; + duration: number; +} + +export interface ProgressProgressiveEvent extends ProgressiveEvent { + status: 'progress'; + value: number; +} + +export const isCompletionProgressiveEvent = (value: unknown): value is CompletionProgressiveEvent => { + return ( + !!value && + typeof value === 'object' && + 'status' in value && + typeof value['status'] === 'string' && + ['canceled', 'completed', 'error'].includes(value['status']) && + 'duration' in value + ); +}; + +export const isProgressProgressiveEvent = (value: unknown): value is ProgressProgressiveEvent => { + return ( + !!value && typeof value === 'object' && 'status' in value && value['status'] === 'progress' && 'value' in value + ); +};