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 + ); +};