From 5b4daf4c72521b29eca7ee39acd32063bb155d2f Mon Sep 17 00:00:00 2001 From: lstocchi Date: Tue, 20 Feb 2024 12:20:02 +0100 Subject: [PATCH 1/7] fix: upload model on podman machine on WSL to speed loading Signed-off-by: lstocchi --- .../src/managers/applicationManager.ts | 9 ++- .../backend/src/managers/modelsManager.ts | 52 +++++++++++- packages/backend/src/models/WSLUploader.ts | 54 +++++++++++++ packages/backend/src/models/uploader.ts | 81 +++++++++++++++++++ packages/backend/src/utils/downloader.ts | 41 ++-------- packages/backend/src/utils/podman.ts | 37 +++++++++ .../backend/src/utils/progressiveEvent.ts | 49 +++++++++++ 7 files changed, 282 insertions(+), 41 deletions(-) create mode 100644 packages/backend/src/models/WSLUploader.ts create mode 100644 packages/backend/src/models/uploader.ts create mode 100644 packages/backend/src/utils/podman.ts create mode 100644 packages/backend/src/utils/progressiveEvent.ts diff --git a/packages/backend/src/managers/applicationManager.ts b/packages/backend/src/managers/applicationManager.ts index 085d4861a..e4064a97b 100644 --- a/packages/backend/src/managers/applicationManager.ts +++ b/packages/backend/src/managers/applicationManager.ts @@ -121,11 +121,18 @@ 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, + }); + + // build all images, one per container (for a basic sample we should have 2 containers = sample app + model service) const images = await this.buildImages( configAndFilteredContainers.containers, diff --git a/packages/backend/src/managers/modelsManager.ts b/packages/backend/src/managers/modelsManager.ts index 1b9fe99e5..6f6474f19 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,45 @@ 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/models/WSLUploader.ts b/packages/backend/src/models/WSLUploader.ts new file mode 100644 index 000000000..92b9fb71d --- /dev/null +++ b/packages/backend/src/models/WSLUploader.ts @@ -0,0 +1,54 @@ +/********************************************************************** + * 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 { getPodmanCli } from '../utils/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/${path.basename(convertToMntPath)}`; + // check if model already loaded on the podman machine + let existsRemote = true; + try { + await podmanDesktopApi.process.exec(getPodmanCli(), ['machine', 'ssh', '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', 'cp', convertToMntPath, remotePath]); + } + + return remotePath; + } +} diff --git a/packages/backend/src/models/uploader.ts b/packages/backend/src/models/uploader.ts new file mode 100644 index 000000000..56b19f353 --- /dev/null +++ b/packages/backend/src/models/uploader.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 { EventEmitter, type Event } from '@podman-desktop/api'; +import { WSLUploader } from './WSLUploader'; +import { getDurationSecondsSince } from '../utils/utils'; +import type { CompletionProgressiveEvent, ProgressiveEvent } from '../utils/progressiveEvent'; + +export interface UploadWorker { + canUpload: () => boolean; + upload: (path: string) => Promise; +} + +export class Uploader { + private 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(): 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({ + 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.ts b/packages/backend/src/utils/podman.ts new file mode 100644 index 000000000..751bd4cc3 --- /dev/null +++ b/packages/backend/src/utils/podman.ts @@ -0,0 +1,37 @@ +/********************************************************************** + * 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 { configuration, env } from '@podman-desktop/api'; + +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'); +} 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 + ); +}; From 5564f3b087dd3c8266b8deea54cb12f0fe73df32 Mon Sep 17 00:00:00 2001 From: lstocchi Date: Tue, 20 Feb 2024 18:06:11 +0100 Subject: [PATCH 2/7] fix: add tests Signed-off-by: lstocchi --- .../src/managers/applicationManager.spec.ts | 3 + .../src/managers/applicationManager.ts | 1 - .../backend/src/managers/modelsManager.ts | 6 +- .../backend/src/models/WSLUploader.spec.ts | 80 +++++++++++++++++++ packages/backend/src/models/uploader.spec.ts | 64 +++++++++++++++ packages/backend/src/models/uploader.ts | 12 +-- packages/backend/src/utils/podman.spec.ts | 67 ++++++++++++++++ 7 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 packages/backend/src/models/WSLUploader.spec.ts create mode 100644 packages/backend/src/models/uploader.spec.ts create mode 100644 packages/backend/src/utils/podman.spec.ts diff --git a/packages/backend/src/managers/applicationManager.spec.ts b/packages/backend/src/managers/applicationManager.spec.ts index 1d8a55f57..2c2d4412a 100644 --- a/packages/backend/src/managers/applicationManager.spec.ts +++ b/packages/backend/src/managers/applicationManager.spec.ts @@ -259,6 +259,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 +322,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 +351,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 e4064a97b..b576c721f 100644 --- a/packages/backend/src/managers/applicationManager.ts +++ b/packages/backend/src/managers/applicationManager.ts @@ -132,7 +132,6 @@ export class ApplicationManager { 'model-id': model.id, }); - // build all images, one per container (for a basic sample we should have 2 containers = sample app + model service) const images = await this.buildImages( configAndFilteredContainers.containers, diff --git a/packages/backend/src/managers/modelsManager.ts b/packages/backend/src/managers/modelsManager.ts index 6f6474f19..840557352 100644 --- a/packages/backend/src/managers/modelsManager.ts +++ b/packages/backend/src/managers/modelsManager.ts @@ -239,7 +239,11 @@ export class ModelsManager implements Disposable { return target; } - async uploadModelToPodmanMachine(model: ModelInfo, localModelPath: string, labels?: { [key: string]: string }): Promise { + 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, diff --git a/packages/backend/src/models/WSLUploader.spec.ts b/packages/backend/src/models/WSLUploader.spec.ts new file mode 100644 index 000000000..b9aa76e75 --- /dev/null +++ b/packages/backend/src/models/WSLUploader.spec.ts @@ -0,0 +1,80 @@ +/********************************************************************** + * 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', () => { + 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'); + 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(''); + await wslUploader.upload('C:\\Users\\podman\\folder\\file'); + expect(mocks.execMock).toBeCalledWith('podman', ['machine', 'ssh', 'stat', '/home/user/file']); + }); + test('do not copy model if it exists on podman machine', async () => { + mocks.execMock.mockResolvedValue(''); + await wslUploader.upload('C:\\Users\\podman\\folder\\file'); + expect(mocks.execMock).toBeCalledWith('podman', ['machine', 'ssh', 'stat', '/home/user/file']); + expect(mocks.execMock).toBeCalledWith('podman', [ + 'machine', + 'ssh', + 'cp', + '/mnt/c/Users/podman/folder/file', + '/home/user/file', + ]); + }); +}); 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 index 56b19f353..9b38e5a29 100644 --- a/packages/backend/src/models/uploader.ts +++ b/packages/backend/src/models/uploader.ts @@ -27,8 +27,8 @@ export interface UploadWorker { } export class Uploader { - private readonly _onEvent = new EventEmitter(); - readonly onEvent: Event = this._onEvent.event; + readonly #_onEvent = new EventEmitter(); + readonly onEvent: Event = this.#_onEvent.event; readonly #workers: UploadWorker[] = []; constructor( @@ -50,7 +50,7 @@ export class Uploader { const startTime = performance.now(); modelPath = await worker.upload(this.localModelPath); const durationSeconds = getDurationSecondsSince(startTime); - this._onEvent.fire({ + this.#_onEvent.fire({ status: 'completed', message: `Duration ${durationSeconds}s.`, duration: durationSeconds, @@ -59,19 +59,19 @@ export class Uploader { } } catch (err) { if (!this.abortSignal?.aborted) { - this._onEvent.fire({ + this.#_onEvent.fire({ status: 'error', message: `Something went wrong: ${String(err)}.`, }); } else { - this._onEvent.fire({ + this.#_onEvent.fire({ status: 'canceled', message: `Request cancelled: ${String(err)}.`, }); } } - this._onEvent.fire({ + this.#_onEvent.fire({ status: 'completed', message: `Use local model`, } as CompletionProgressiveEvent); diff --git a/packages/backend/src/utils/podman.spec.ts b/packages/backend/src/utils/podman.spec.ts new file mode 100644 index 000000000..f636d0540 --- /dev/null +++ b/packages/backend/src/utils/podman.spec.ts @@ -0,0 +1,67 @@ +/********************************************************************** + * 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(), + }; +}); + +const config: podmanDesktopApi.Configuration = { + get: mocks.getConfigurationMock, + has: () => true, + update: () => Promise.resolve(), +}; + +vi.mock('@podman-desktop/api', () => ({ + env: { + isWindows: false, + }, + configuration: { + getConfiguration: () => config, + }, +})); + +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'); + }); +}); From de9085b5d22b944e79384883a7dab1bd93f76982 Mon Sep 17 00:00:00 2001 From: lstocchi Date: Mon, 26 Feb 2024 14:34:16 +0100 Subject: [PATCH 3/7] fix: get running machine name and use it on podman cp/stat Signed-off-by: lstocchi --- .../backend/src/managers/playground.spec.ts | 15 +++++--- packages/backend/src/managers/playground.ts | 11 ++---- .../src/managers/podmanConnection.spec.ts | 21 ++++++----- .../backend/src/managers/podmanConnection.ts | 11 +++--- .../backend/src/models/WSLUploader.spec.ts | 22 +++++++++--- packages/backend/src/models/WSLUploader.ts | 7 ++-- packages/backend/src/utils/podman.ts | 35 ++++++++++++++++++- 7 files changed, 86 insertions(+), 36 deletions(-) diff --git a/packages/backend/src/managers/playground.spec.ts b/packages/backend/src/managers/playground.spec.ts index b954fac0a..03afa2c3b 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,14 @@ 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([ + mocks.getFirstRunningPodmanConnectionMock.mockResolvedValue( { connection: { type: 'podman', status: () => 'started', }, }, - ]); + ); vi.spyOn(manager, 'selectImage') .mockResolvedValueOnce(undefined) .mockResolvedValueOnce({ @@ -157,14 +164,14 @@ 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([ + mocks.getFirstRunningPodmanConnectionMock.mockResolvedValue( { 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..8e5a22cea 100644 --- a/packages/backend/src/managers/playground.ts +++ b/packages/backend/src/managers/playground.ts @@ -35,6 +35,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 +45,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 +164,7 @@ export class PlayGroundManager { this.setPlaygroundStatus(modelId, 'starting'); - const connection = findFirstProvider(); + const connection = await 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..bd19bd6cc 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,27 +30,32 @@ 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([ + mocks.getFirstRunningPodmanConnectionMock.mockResolvedValue( { connection: { type: 'podman', status: () => 'started', }, }, - ]); + ); mocks.onDidRegisterContainerConnection.mockReturnValue({ dispose: vi.fn, }); - manager.listenRegistration(); + await manager.listenRegistration(); const handler = vi.fn(); manager.startupSubscribe(handler); // the handler is called immediately @@ -61,7 +66,7 @@ test('startupSubscribe should execute when provider is registered', async () => const manager = new PodmanConnection(); // no provider is already registered - mocks.getContainerConnections.mockReturnValue([]); + mocks.getFirstRunningPodmanConnectionMock.mockResolvedValue(undefined); mocks.onDidRegisterContainerConnection.mockImplementation((f: (e: RegisterContainerConnectionEvent) => void) => { setTimeout(() => { f({ @@ -75,7 +80,7 @@ test('startupSubscribe should execute when provider is registered', async () => dispose: vi.fn(), }; }); - manager.listenRegistration(); + await manager.listenRegistration(); const handler = vi.fn(); manager.startupSubscribe(handler); // the handler is not called immediately diff --git a/packages/backend/src/managers/podmanConnection.ts b/packages/backend/src/managers/podmanConnection.ts index 09a8d6b42..647d241a9 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; @@ -53,7 +54,7 @@ export class PodmanConnection implements Disposable { this.#onEventDisposable?.dispose(); } - listenRegistration() { + async listenRegistration() { // In case the extension has not yet registered, we listen for new registrations // and retain the first started podman provider const disposable = provider.onDidRegisterContainerConnection((e: RegisterContainerConnectionEvent) => { @@ -72,14 +73,12 @@ 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 = await getFirstRunningPodmanConnection(); + if (engine) { disposable.dispose(); this.#firstFound = true; } + } // startupSubscribe registers f to be executed when a podman container provider diff --git a/packages/backend/src/models/WSLUploader.spec.ts b/packages/backend/src/models/WSLUploader.spec.ts index b9aa76e75..465608b98 100644 --- a/packages/backend/src/models/WSLUploader.spec.ts +++ b/packages/backend/src/models/WSLUploader.spec.ts @@ -36,6 +36,7 @@ vi.mock('@podman-desktop/api', () => ({ exec: mocks.execMock, }, })); + const wslUploader = new WSLUploader(); beforeEach(() => { @@ -57,21 +58,32 @@ describe('canUpload', () => { describe('upload', () => { vi.spyOn(utils, 'getPodmanCli').mockReturnValue('podman'); - test('throw if localpath is not defined', async () => { - await expect(wslUploader.upload('')).rejects.toThrowError('invalid local path'); + 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(''); + mocks.execMock.mockRejectedValueOnce(''); await wslUploader.upload('C:\\Users\\podman\\folder\\file'); - expect(mocks.execMock).toBeCalledWith('podman', ['machine', 'ssh', 'stat', '/home/user/file']); + expect(mocks.execMock).toBeCalledWith('podman', ['machine', 'ssh', 'test', 'stat', '/home/user/file']); }); test('do not copy model if it exists on podman machine', async () => { mocks.execMock.mockResolvedValue(''); await wslUploader.upload('C:\\Users\\podman\\folder\\file'); - expect(mocks.execMock).toBeCalledWith('podman', ['machine', 'ssh', 'stat', '/home/user/file']); + expect(mocks.execMock).toBeCalledWith('podman', ['machine', 'ssh', 'test', 'stat', '/home/user/file']); expect(mocks.execMock).toBeCalledWith('podman', [ 'machine', 'ssh', + 'test', '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 index 92b9fb71d..d1e9a41ca 100644 --- a/packages/backend/src/models/WSLUploader.ts +++ b/packages/backend/src/models/WSLUploader.ts @@ -18,7 +18,7 @@ import path from 'node:path'; import * as podmanDesktopApi from '@podman-desktop/api'; -import { getPodmanCli } from '../utils/podman'; +import { getFirstRunningPodmanConnection, getPodmanCli } from '../utils/podman'; import type { UploadWorker } from './uploader'; export class WSLUploader implements UploadWorker { @@ -36,17 +36,18 @@ export class WSLUploader implements UploadWorker { .replace(`${driveLetter}:\\`, `/mnt/${driveLetter.toLowerCase()}/`) .replace(/\\/g, '/'); const remotePath = `/home/user/${path.basename(convertToMntPath)}`; + const connection = await getFirstRunningPodmanConnection(); // check if model already loaded on the podman machine let existsRemote = true; try { - await podmanDesktopApi.process.exec(getPodmanCli(), ['machine', 'ssh', 'stat', remotePath]); + await podmanDesktopApi.process.exec(getPodmanCli(), ['machine', 'ssh', connection.connection.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', 'cp', convertToMntPath, remotePath]); + await podmanDesktopApi.process.exec(getPodmanCli(), ['machine', 'ssh', connection.connection.name, 'cp', convertToMntPath, remotePath]); } return remotePath; diff --git a/packages/backend/src/utils/podman.ts b/packages/backend/src/utils/podman.ts index 751bd4cc3..b0e28ff19 100644 --- a/packages/backend/src/utils/podman.ts +++ b/packages/backend/src/utils/podman.ts @@ -15,7 +15,18 @@ * * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ -import { configuration, env } from '@podman-desktop/api'; +import { ProviderContainerConnection, 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; +}; export function getPodmanCli(): string { // If we have a custom binary path regardless if we are running Windows or not @@ -35,3 +46,25 @@ export function getPodmanCli(): string { 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 getFirstRunningPodmanConnection(): Promise { + let engine: ProviderContainerConnection; + try { + const machineListOutput = await getJSONMachineList(); + const machines = JSON.parse(machineListOutput) as MachineJSON[]; + const machine = machines.find(machine => machine.Default && machine.Running); + engine = provider + .getContainerConnections() + .filter(connection => connection.connection.type === 'podman') + .find(connection => connection.connection.name === machine.Name); + } catch(e) { + console.log(e) + } + + return engine; +} \ No newline at end of file From a0af678c912c7181579f24c9ee143a8307bc5cea Mon Sep 17 00:00:00 2001 From: lstocchi Date: Mon, 26 Feb 2024 16:42:31 +0100 Subject: [PATCH 4/7] fix: fix lint, format and add tests Signed-off-by: lstocchi --- .../backend/src/managers/playground.spec.ts | 24 +++-- packages/backend/src/managers/playground.ts | 9 +- .../src/managers/podmanConnection.spec.ts | 12 ++- .../backend/src/managers/podmanConnection.ts | 3 +- .../backend/src/models/WSLUploader.spec.ts | 9 +- packages/backend/src/models/WSLUploader.ts | 17 +++- packages/backend/src/utils/podman.spec.ts | 90 +++++++++++++++++++ packages/backend/src/utils/podman.ts | 23 ++--- 8 files changed, 140 insertions(+), 47 deletions(-) diff --git a/packages/backend/src/managers/playground.spec.ts b/packages/backend/src/managers/playground.spec.ts index 03afa2c3b..cb865d663 100644 --- a/packages/backend/src/managers/playground.spec.ts +++ b/packages/backend/src/managers/playground.spec.ts @@ -106,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.getFirstRunningPodmanConnectionMock.mockResolvedValue( - { - connection: { - type: 'podman', - status: () => 'started', - }, + mocks.getFirstRunningPodmanConnectionMock.mockResolvedValue({ + connection: { + type: 'podman', + status: () => 'started', }, - ); + }); vi.spyOn(manager, 'selectImage') .mockResolvedValueOnce(undefined) .mockResolvedValueOnce({ @@ -164,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.getFirstRunningPodmanConnectionMock.mockResolvedValue( - { - connection: { - type: 'podman', - status: () => 'started', - }, + mocks.getFirstRunningPodmanConnectionMock.mockResolvedValue({ + 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 8e5a22cea..448efd866 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'; diff --git a/packages/backend/src/managers/podmanConnection.spec.ts b/packages/backend/src/managers/podmanConnection.spec.ts index bd19bd6cc..16d402bd7 100644 --- a/packages/backend/src/managers/podmanConnection.spec.ts +++ b/packages/backend/src/managers/podmanConnection.spec.ts @@ -44,14 +44,12 @@ vi.mock('../utils/podman', () => { test('startupSubscribe should execute immediately if provider already registered', async () => { const manager = new PodmanConnection(); // one provider is already registered - mocks.getFirstRunningPodmanConnectionMock.mockResolvedValue( - { - connection: { - type: 'podman', - status: () => 'started', - }, + mocks.getFirstRunningPodmanConnectionMock.mockResolvedValue({ + connection: { + type: 'podman', + status: () => 'started', }, - ); + }); mocks.onDidRegisterContainerConnection.mockReturnValue({ dispose: vi.fn, }); diff --git a/packages/backend/src/managers/podmanConnection.ts b/packages/backend/src/managers/podmanConnection.ts index 647d241a9..36f6dbefa 100644 --- a/packages/backend/src/managers/podmanConnection.ts +++ b/packages/backend/src/managers/podmanConnection.ts @@ -45,7 +45,7 @@ export class PodmanConnection implements Disposable { #onEventDisposable: Disposable | undefined; init(): void { - this.listenRegistration(); + this.listenRegistration().catch((e: unknown) => console.error(String(e))); this.listenMachine(); this.watchPods(); } @@ -78,7 +78,6 @@ export class PodmanConnection implements Disposable { disposable.dispose(); this.#firstFound = true; } - } // startupSubscribe registers f to be executed when a podman container provider diff --git a/packages/backend/src/models/WSLUploader.spec.ts b/packages/backend/src/models/WSLUploader.spec.ts index 465608b98..4f0d1a74f 100644 --- a/packages/backend/src/models/WSLUploader.spec.ts +++ b/packages/backend/src/models/WSLUploader.spec.ts @@ -65,14 +65,15 @@ describe('upload', () => { endpoint: { socketPath: '/endpoint.sock', }, - type: 'podman' + type: 'podman', }, - providerId: 'podman' + providerId: 'podman', }); test('throw if localpath is not defined', async () => { - await expect(wslUploader.upload('')).rejects.toThrowError('invalid local path'); }); + await expect(wslUploader.upload('')).rejects.toThrowError('invalid local path'); + }); test('copy model if not exists on podman machine', async () => { - mocks.execMock.mockRejectedValueOnce(''); + mocks.execMock.mockRejectedValueOnce(''); await wslUploader.upload('C:\\Users\\podman\\folder\\file'); expect(mocks.execMock).toBeCalledWith('podman', ['machine', 'ssh', 'test', 'stat', '/home/user/file']); }); diff --git a/packages/backend/src/models/WSLUploader.ts b/packages/backend/src/models/WSLUploader.ts index d1e9a41ca..f03845d3f 100644 --- a/packages/backend/src/models/WSLUploader.ts +++ b/packages/backend/src/models/WSLUploader.ts @@ -40,14 +40,27 @@ export class WSLUploader implements UploadWorker { // check if model already loaded on the podman machine let existsRemote = true; try { - await podmanDesktopApi.process.exec(getPodmanCli(), ['machine', 'ssh', connection.connection.name, 'stat', remotePath]); + await podmanDesktopApi.process.exec(getPodmanCli(), [ + 'machine', + 'ssh', + connection.connection.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', connection.connection.name, 'cp', convertToMntPath, remotePath]); + await podmanDesktopApi.process.exec(getPodmanCli(), [ + 'machine', + 'ssh', + connection.connection.name, + 'cp', + convertToMntPath, + remotePath, + ]); } return remotePath; diff --git a/packages/backend/src/utils/podman.spec.ts b/packages/backend/src/utils/podman.spec.ts index f636d0540..0b856fa78 100644 --- a/packages/backend/src/utils/podman.spec.ts +++ b/packages/backend/src/utils/podman.spec.ts @@ -24,6 +24,8 @@ import { beforeEach } from 'node:test'; const mocks = vi.hoisted(() => { return { getConfigurationMock: vi.fn(), + execMock: vi.fn(), + getContainerConnectionsMock: vi.fn(), }; }); @@ -40,6 +42,12 @@ vi.mock('@podman-desktop/api', () => ({ configuration: { getConfiguration: () => config, }, + process: { + exec: mocks.execMock, + }, + provider: { + getContainerConnections: mocks.getContainerConnectionsMock, + }, })); beforeEach(() => { @@ -65,3 +73,85 @@ describe('getPodmanCli', () => { expect(result).equals('podman'); }); }); + +describe('getFirstRunningPodmanConnection', () => { + test('should return undefined if failing at retrieving machine list', async () => { + mocks.execMock.mockRejectedValue('error'); + const result = await utils.getFirstRunningPodmanConnection(); + expect(result).toBeUndefined(); + }); + test('should return undefined if default podman machine is not running', async () => { + const machine = { + Name: 'machine', + CPUs: 2, + Memory: 2000, + DiskSize: '100', + Running: false, + Starting: false, + Default: true, + }; + const machine2 = { + Name: 'machine2', + CPUs: 2, + Memory: 2000, + DiskSize: '100', + Running: true, + Starting: false, + Default: false, + }; + mocks.execMock.mockResolvedValue({ + stdout: JSON.stringify([machine2, machine]), + }); + const result = await utils.getFirstRunningPodmanConnection(); + expect(result).toBeUndefined(); + }); + test('should return default running podman connection', async () => { + const machine = { + Name: 'machine', + CPUs: 2, + Memory: 2000, + DiskSize: '100', + Running: false, + Starting: false, + Default: false, + }; + const machine2 = { + Name: 'machine2', + CPUs: 2, + Memory: 2000, + DiskSize: '100', + Running: true, + Starting: false, + Default: true, + }; + mocks.execMock.mockResolvedValue({ + stdout: JSON.stringify([machine, machine2]), + }); + mocks.getContainerConnectionsMock.mockReturnValue([ + { + connection: { + name: 'machine', + status: vi.fn(), + endpoint: { + socketPath: '/endpoint.sock', + }, + type: 'podman', + }, + providerId: 'podman', + }, + { + connection: { + name: 'machine2', + status: vi.fn(), + endpoint: { + socketPath: '/endpoint.sock', + }, + type: 'podman', + }, + providerId: 'podman2', + }, + ]); + const result = await utils.getFirstRunningPodmanConnection(); + expect(result.connection.name).equal('machine2'); + }); +}); diff --git a/packages/backend/src/utils/podman.ts b/packages/backend/src/utils/podman.ts index b0e28ff19..b874a319b 100644 --- a/packages/backend/src/utils/podman.ts +++ b/packages/backend/src/utils/podman.ts @@ -15,7 +15,8 @@ * * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ -import { ProviderContainerConnection, configuration, env, process, provider } from '@podman-desktop/api'; +import type { ProviderContainerConnection } from '@podman-desktop/api'; +import { configuration, env, process, provider } from '@podman-desktop/api'; export type MachineJSON = { Name: string; @@ -58,13 +59,15 @@ export async function getFirstRunningPodmanConnection(): Promise machine.Default && machine.Running); - engine = provider - .getContainerConnections() - .filter(connection => connection.connection.type === 'podman') - .find(connection => connection.connection.name === machine.Name); - } catch(e) { - console.log(e) - } - + if (machine) { + engine = provider + .getContainerConnections() + .filter(connection => connection.connection.type === 'podman') + .find(connection => connection.connection.name === machine.Name); + } + } catch (e) { + console.log(e); + } + return engine; -} \ No newline at end of file +} From c957f6d67be3cedc4a24ac17b77b40f0053ccbcf Mon Sep 17 00:00:00 2001 From: lstocchi Date: Thu, 29 Feb 2024 18:31:39 +0100 Subject: [PATCH 5/7] fix: use machine name to execute podman cli Signed-off-by: lstocchi --- .../backend/src/managers/playground.spec.ts | 4 +- packages/backend/src/managers/playground.ts | 2 +- .../src/managers/podmanConnection.spec.ts | 8 +- .../backend/src/managers/podmanConnection.ts | 6 +- .../backend/src/models/WSLUploader.spec.ts | 19 ++++- packages/backend/src/models/WSLUploader.ts | 14 +--- packages/backend/src/utils/podman.spec.ts | 84 +++++++------------ packages/backend/src/utils/podman.ts | 25 ++++-- 8 files changed, 76 insertions(+), 86 deletions(-) diff --git a/packages/backend/src/managers/playground.spec.ts b/packages/backend/src/managers/playground.spec.ts index cb865d663..d3c040f08 100644 --- a/packages/backend/src/managers/playground.spec.ts +++ b/packages/backend/src/managers/playground.spec.ts @@ -106,7 +106,7 @@ 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.getFirstRunningPodmanConnectionMock.mockResolvedValue({ + mocks.getFirstRunningPodmanConnectionMock.mockReturnValue({ connection: { type: 'podman', status: () => 'started', @@ -162,7 +162,7 @@ test('stopPlayground should fail if no playground is running', async () => { test('stopPlayground should stop a started playground', async () => { mocks.postMessage.mockResolvedValue(undefined); - mocks.getFirstRunningPodmanConnectionMock.mockResolvedValue({ + mocks.getFirstRunningPodmanConnectionMock.mockReturnValue({ connection: { type: 'podman', status: () => 'started', diff --git a/packages/backend/src/managers/playground.ts b/packages/backend/src/managers/playground.ts index 448efd866..9cbd280aa 100644 --- a/packages/backend/src/managers/playground.ts +++ b/packages/backend/src/managers/playground.ts @@ -157,7 +157,7 @@ export class PlayGroundManager { this.setPlaygroundStatus(modelId, 'starting'); - const connection = await getFirstRunningPodmanConnection(); + 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 16d402bd7..015e4db31 100644 --- a/packages/backend/src/managers/podmanConnection.spec.ts +++ b/packages/backend/src/managers/podmanConnection.spec.ts @@ -44,7 +44,7 @@ vi.mock('../utils/podman', () => { test('startupSubscribe should execute immediately if provider already registered', async () => { const manager = new PodmanConnection(); // one provider is already registered - mocks.getFirstRunningPodmanConnectionMock.mockResolvedValue({ + mocks.getFirstRunningPodmanConnectionMock.mockReturnValue({ connection: { type: 'podman', status: () => 'started', @@ -53,7 +53,7 @@ test('startupSubscribe should execute immediately if provider already registered mocks.onDidRegisterContainerConnection.mockReturnValue({ dispose: vi.fn, }); - await manager.listenRegistration(); + manager.listenRegistration(); const handler = vi.fn(); manager.startupSubscribe(handler); // the handler is called immediately @@ -64,7 +64,7 @@ test('startupSubscribe should execute when provider is registered', async () => const manager = new PodmanConnection(); // no provider is already registered - mocks.getFirstRunningPodmanConnectionMock.mockResolvedValue(undefined); + mocks.getFirstRunningPodmanConnectionMock.mockReturnValue(undefined); mocks.onDidRegisterContainerConnection.mockImplementation((f: (e: RegisterContainerConnectionEvent) => void) => { setTimeout(() => { f({ @@ -78,7 +78,7 @@ test('startupSubscribe should execute when provider is registered', async () => dispose: vi.fn(), }; }); - await manager.listenRegistration(); + manager.listenRegistration(); const handler = vi.fn(); manager.startupSubscribe(handler); // the handler is not called immediately diff --git a/packages/backend/src/managers/podmanConnection.ts b/packages/backend/src/managers/podmanConnection.ts index 36f6dbefa..8b6be0641 100644 --- a/packages/backend/src/managers/podmanConnection.ts +++ b/packages/backend/src/managers/podmanConnection.ts @@ -45,7 +45,7 @@ export class PodmanConnection implements Disposable { #onEventDisposable: Disposable | undefined; init(): void { - this.listenRegistration().catch((e: unknown) => console.error(String(e))); + this.listenRegistration(); this.listenMachine(); this.watchPods(); } @@ -54,7 +54,7 @@ export class PodmanConnection implements Disposable { this.#onEventDisposable?.dispose(); } - async listenRegistration() { + listenRegistration() { // In case the extension has not yet registered, we listen for new registrations // and retain the first started podman provider const disposable = provider.onDidRegisterContainerConnection((e: RegisterContainerConnectionEvent) => { @@ -73,7 +73,7 @@ export class PodmanConnection implements Disposable { }); // In case at least one extension has already registered, we get one started podman provider - const engine = await getFirstRunningPodmanConnection(); + const engine = getFirstRunningPodmanConnection(); if (engine) { disposable.dispose(); this.#firstFound = true; diff --git a/packages/backend/src/models/WSLUploader.spec.ts b/packages/backend/src/models/WSLUploader.spec.ts index 4f0d1a74f..04c6ff385 100644 --- a/packages/backend/src/models/WSLUploader.spec.ts +++ b/packages/backend/src/models/WSLUploader.spec.ts @@ -57,6 +57,15 @@ describe('canUpload', () => { }); 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: { @@ -73,18 +82,20 @@ describe('upload', () => { await expect(wslUploader.upload('')).rejects.toThrowError('invalid local path'); }); test('copy model if not exists on podman machine', async () => { - mocks.execMock.mockRejectedValueOnce(''); + 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', 'test', 'stat', '/home/user/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', 'test', 'stat', '/home/user/file']); + expect(mocks.execMock).toBeCalledWith('podman', ['machine', 'ssh', 'machine2', 'stat', '/home/user/file']); expect(mocks.execMock).toBeCalledWith('podman', [ 'machine', 'ssh', - 'test', + '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 index f03845d3f..e70ec3124 100644 --- a/packages/backend/src/models/WSLUploader.ts +++ b/packages/backend/src/models/WSLUploader.ts @@ -18,7 +18,7 @@ import path from 'node:path'; import * as podmanDesktopApi from '@podman-desktop/api'; -import { getFirstRunningPodmanConnection, getPodmanCli } from '../utils/podman'; +import { getFirstRunningMachine, getPodmanCli } from '../utils/podman'; import type { UploadWorker } from './uploader'; export class WSLUploader implements UploadWorker { @@ -36,17 +36,11 @@ export class WSLUploader implements UploadWorker { .replace(`${driveLetter}:\\`, `/mnt/${driveLetter.toLowerCase()}/`) .replace(/\\/g, '/'); const remotePath = `/home/user/${path.basename(convertToMntPath)}`; - const connection = await getFirstRunningPodmanConnection(); + const machine = await getFirstRunningMachine(); // check if model already loaded on the podman machine let existsRemote = true; try { - await podmanDesktopApi.process.exec(getPodmanCli(), [ - 'machine', - 'ssh', - connection.connection.name, - 'stat', - remotePath, - ]); + await podmanDesktopApi.process.exec(getPodmanCli(), ['machine', 'ssh', machine.Name, 'stat', remotePath]); } catch (e) { existsRemote = false; } @@ -56,7 +50,7 @@ export class WSLUploader implements UploadWorker { await podmanDesktopApi.process.exec(getPodmanCli(), [ 'machine', 'ssh', - connection.connection.name, + machine.Name, 'cp', convertToMntPath, remotePath, diff --git a/packages/backend/src/utils/podman.spec.ts b/packages/backend/src/utils/podman.spec.ts index 0b856fa78..7a5a49251 100644 --- a/packages/backend/src/utils/podman.spec.ts +++ b/packages/backend/src/utils/podman.spec.ts @@ -24,7 +24,6 @@ import { beforeEach } from 'node:test'; const mocks = vi.hoisted(() => { return { getConfigurationMock: vi.fn(), - execMock: vi.fn(), getContainerConnectionsMock: vi.fn(), }; }); @@ -42,9 +41,6 @@ vi.mock('@podman-desktop/api', () => ({ configuration: { getConfiguration: () => config, }, - process: { - exec: mocks.execMock, - }, provider: { getContainerConnections: mocks.getContainerConnectionsMock, }, @@ -75,63 +71,45 @@ describe('getPodmanCli', () => { }); describe('getFirstRunningPodmanConnection', () => { - test('should return undefined if failing at retrieving machine list', async () => { - mocks.execMock.mockRejectedValue('error'); - const result = await utils.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 () => { - const machine = { - Name: 'machine', - CPUs: 2, - Memory: 2000, - DiskSize: '100', - Running: false, - Starting: false, - Default: true, - }; - const machine2 = { - Name: 'machine2', - CPUs: 2, - Memory: 2000, - DiskSize: '100', - Running: true, - Starting: false, - Default: false, - }; - mocks.execMock.mockResolvedValue({ - stdout: JSON.stringify([machine2, machine]), - }); - const result = await utils.getFirstRunningPodmanConnection(); + 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 () => { - const machine = { - Name: 'machine', - CPUs: 2, - Memory: 2000, - DiskSize: '100', - Running: false, - Starting: false, - Default: false, - }; - const machine2 = { - Name: 'machine2', - CPUs: 2, - Memory: 2000, - DiskSize: '100', - Running: true, - Starting: false, - Default: true, - }; - mocks.execMock.mockResolvedValue({ - stdout: JSON.stringify([machine, machine2]), - }); mocks.getContainerConnectionsMock.mockReturnValue([ { connection: { name: 'machine', - status: vi.fn(), + status: () => 'stopped', endpoint: { socketPath: '/endpoint.sock', }, @@ -142,7 +120,7 @@ describe('getFirstRunningPodmanConnection', () => { { connection: { name: 'machine2', - status: vi.fn(), + status: () => 'started', endpoint: { socketPath: '/endpoint.sock', }, @@ -151,7 +129,7 @@ describe('getFirstRunningPodmanConnection', () => { providerId: 'podman2', }, ]); - const result = await utils.getFirstRunningPodmanConnection(); + 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 index b874a319b..10b989393 100644 --- a/packages/backend/src/utils/podman.ts +++ b/packages/backend/src/utils/podman.ts @@ -53,18 +53,25 @@ async function getJSONMachineList(): Promise { return stdout; } -export async function getFirstRunningPodmanConnection(): Promise { - let engine: ProviderContainerConnection; +export async function getFirstRunningMachine(): Promise { try { const machineListOutput = await getJSONMachineList(); const machines = JSON.parse(machineListOutput) as MachineJSON[]; - const machine = machines.find(machine => machine.Default && machine.Running); - if (machine) { - engine = provider - .getContainerConnections() - .filter(connection => connection.connection.type === 'podman') - .find(connection => connection.connection.name === machine.Name); - } + 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); } From 06565f8f59c82c94a4036dde548cee5c9773ebc1 Mon Sep 17 00:00:00 2001 From: Luca Stocchi Date: Fri, 1 Mar 2024 09:52:28 +0100 Subject: [PATCH 6/7] feat: upload model on qemu --- .../src/managers/applicationManager.ts | 33 ++++------- packages/backend/src/models/QemuUploader.ts | 59 +++++++++++++++++++ packages/backend/src/models/WSLUploader.ts | 2 +- packages/backend/src/models/uploader.ts | 13 +++- packages/backend/src/utils/podman.ts | 1 + 5 files changed, 81 insertions(+), 27 deletions(-) create mode 100644 packages/backend/src/models/QemuUploader.ts diff --git a/packages/backend/src/managers/applicationManager.ts b/packages/backend/src/managers/applicationManager.ts index b576c721f..248b5accb 100644 --- a/packages/backend/src/managers/applicationManager.ts +++ b/packages/backend/src/managers/applicationManager.ts @@ -264,6 +264,7 @@ export class ApplicationManager { Target: `/${modelName}`, Source: modelPath, Type: 'bind', + Mode: 'Z', }, ], }; @@ -279,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/models/QemuUploader.ts b/packages/backend/src/models/QemuUploader.ts new file mode 100644 index 000000000..bc9c00fdb --- /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.ts b/packages/backend/src/models/WSLUploader.ts index e70ec3124..3bfac5ce8 100644 --- a/packages/backend/src/models/WSLUploader.ts +++ b/packages/backend/src/models/WSLUploader.ts @@ -22,7 +22,7 @@ import { getFirstRunningMachine, getPodmanCli } from '../utils/podman'; import type { UploadWorker } from './uploader'; export class WSLUploader implements UploadWorker { - canUpload(): boolean { + async canUpload(): Promise { return podmanDesktopApi.env.isWindows; } diff --git a/packages/backend/src/models/uploader.ts b/packages/backend/src/models/uploader.ts index 9b38e5a29..0904824af 100644 --- a/packages/backend/src/models/uploader.ts +++ b/packages/backend/src/models/uploader.ts @@ -20,9 +20,10 @@ 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: () => boolean; + canUpload: () => Promise; upload: (path: string) => Promise; } @@ -35,11 +36,17 @@ export class Uploader { private localModelPath: string, private abortSignal?: AbortSignal, ) { - this.#workers = [new WSLUploader()]; + this.#workers = [new WSLUploader(), new QemuUploader()]; } async perform(): Promise { - const workers = this.#workers.filter(w => w.canUpload()); + 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) { diff --git a/packages/backend/src/utils/podman.ts b/packages/backend/src/utils/podman.ts index 10b989393..909de2ed7 100644 --- a/packages/backend/src/utils/podman.ts +++ b/packages/backend/src/utils/podman.ts @@ -27,6 +27,7 @@ export type MachineJSON = { Starting: boolean; Default: boolean; UserModeNetworking?: boolean; + VMType?: string; }; export function getPodmanCli(): string { From 2188312837b1c55c2f9f6bffa5c5fa71218f1dc6 Mon Sep 17 00:00:00 2001 From: lstocchi Date: Fri, 1 Mar 2024 10:27:39 +0100 Subject: [PATCH 7/7] fix: add tests Signed-off-by: lstocchi --- .../src/managers/applicationManager.spec.ts | 22 +--- .../backend/src/models/QemuUploader.spec.ts | 111 ++++++++++++++++++ packages/backend/src/models/QemuUploader.ts | 2 +- .../backend/src/models/WSLUploader.spec.ts | 8 +- 4 files changed, 119 insertions(+), 24 deletions(-) create mode 100644 packages/backend/src/models/QemuUploader.spec.ts diff --git a/packages/backend/src/managers/applicationManager.spec.ts b/packages/backend/src/managers/applicationManager.spec.ts index 2c2d4412a..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, @@ -1010,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', }); @@ -1018,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/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 index bc9c00fdb..20b33eaa1 100644 --- a/packages/backend/src/models/QemuUploader.ts +++ b/packages/backend/src/models/QemuUploader.ts @@ -24,7 +24,7 @@ import path from 'node:path'; export class QemuUploader implements UploadWorker { async canUpload(): Promise { const machine = await getFirstRunningMachine(); - return machine.VMType === 'qemu'; + return machine?.VMType === 'qemu'; } async upload(localPath: string): Promise { diff --git a/packages/backend/src/models/WSLUploader.spec.ts b/packages/backend/src/models/WSLUploader.spec.ts index 04c6ff385..c1cb5549a 100644 --- a/packages/backend/src/models/WSLUploader.spec.ts +++ b/packages/backend/src/models/WSLUploader.spec.ts @@ -44,14 +44,14 @@ beforeEach(() => { }); describe('canUpload', () => { - test('should return false if system is not windows', () => { + test('should return false if system is not windows', async () => { vi.mocked(podmanDesktopApi.env).isWindows = false; - const result = wslUploader.canUpload(); + const result = await wslUploader.canUpload(); expect(result).toBeFalsy(); }); - test('should return true if system is windows', () => { + test('should return true if system is windows', async () => { vi.mocked(podmanDesktopApi.env).isWindows = true; - const result = wslUploader.canUpload(); + const result = await wslUploader.canUpload(); expect(result).toBeTruthy(); }); });