diff --git a/packages/backend/src/managers/applicationManager.spec.ts b/packages/backend/src/managers/applicationManager.spec.ts index cdbe1a8b3..eb84160e5 100644 --- a/packages/backend/src/managers/applicationManager.spec.ts +++ b/packages/backend/src/managers/applicationManager.spec.ts @@ -97,7 +97,7 @@ describe('pullApplication', () => { } as unknown as ModelsManager, ); - downloadModelMainSpy = vi.spyOn(manager, 'downloadModelMain'); + downloadModelMainSpy = vi.spyOn(manager, 'doDownloadModelWrapper'); downloadModelMainSpy.mockResolvedValue(''); } diff --git a/packages/backend/src/managers/applicationManager.ts b/packages/backend/src/managers/applicationManager.ts index 940b562c2..9ca6fdb6b 100644 --- a/packages/backend/src/managers/applicationManager.ts +++ b/packages/backend/src/managers/applicationManager.ts @@ -24,7 +24,7 @@ import * as https from 'node:https'; import * as path from 'node:path'; import { containerEngine } from '@podman-desktop/api'; import type { RecipeStatusRegistry } from '../registries/RecipeStatusRegistry'; -import type { AIConfig } from '../models/AIConfig'; +import type { AIConfig, AIConfigFile, ContainerConfig } from '../models/AIConfig'; import { parseYaml } from '../models/AIConfig'; import type { Task } from '@shared/src/models/ITask'; import { RecipeStatusUtils } from '../utils/recipeStatusUtils'; @@ -53,111 +53,23 @@ export class ApplicationManager { const localFolder = path.join(this.appUserDirectory, recipe.id); - // Adding checkout task - const checkoutTask: Task = { - id: 'checkout', - name: 'Checkout repository', - state: 'loading', - labels: { - git: 'checkout', - }, - }; - taskUtil.setTask(checkoutTask); - - // We might already have the repository cloned - if (fs.existsSync(localFolder) && fs.statSync(localFolder).isDirectory()) { - // Update checkout state - checkoutTask.name = 'Checkout repository (cached).'; - checkoutTask.state = 'success'; - } else { - // Create folder - fs.mkdirSync(localFolder, { recursive: true }); - - // Clone the repository - console.log(`Cloning repository ${recipe.repository} in ${localFolder}.`); - await this.git.cloneRepository(recipe.repository, localFolder); - - // Update checkout state - checkoutTask.state = 'success'; - } - // Update task - taskUtil.setTask(checkoutTask); + // clone the recipe repository on the local folder + await this.doCheckout(recipe.repository, localFolder, taskUtil); - // Adding loading configuration task - const loadingConfiguration: Task = { - id: 'loading-config', - name: 'Loading configuration', - state: 'loading', - }; - taskUtil.setTask(loadingConfiguration); + // load and parse the recipe configuration file + const aiConfigFile = this.getConfiguration(recipe.config, localFolder, taskUtil); - let configFile: string; - if (recipe.config !== undefined) { - configFile = path.join(localFolder, recipe.config); - } else { - configFile = path.join(localFolder, CONFIG_FILENAME); - } + // get model by downloading it or retrieving locally + await this.downloadModel(model, taskUtil); - if (!fs.existsSync(configFile)) { - loadingConfiguration.state = 'error'; - taskUtil.setTask(loadingConfiguration); - throw new Error(`The file located at ${configFile} does not exist.`); - } + // filter the containers based on architecture, gpu accelerator and backend (that define which model supports) + const filteredContainers: ContainerConfig[] = this.filterContainers(aiConfigFile.aiConfig); - // If the user configured the config as a directory we check for "ai-studio.yaml" inside. - if (fs.statSync(configFile).isDirectory()) { - const tmpPath = path.join(configFile, CONFIG_FILENAME); - // If it has the ai-studio.yaml we use it. - if (fs.existsSync(tmpPath)) { - configFile = tmpPath; - } - } - - // Parsing the configuration - console.log(`Reading configuration from ${configFile}.`); - const rawConfiguration = fs.readFileSync(configFile, 'utf-8'); - let aiConfig: AIConfig; - try { - aiConfig = parseYaml(rawConfiguration, arch()); - } catch (err) { - // Mask task as failed - loadingConfiguration.state = 'error'; - taskUtil.setTask(loadingConfiguration); - throw new Error('Cannot load configuration file.'); - } - - // Mark as success. - loadingConfiguration.state = 'success'; - taskUtil.setTask(loadingConfiguration); - - // Filter the containers based on architecture - const filteredContainers = aiConfig.application.containers.filter( - container => container.arch === undefined || container.arch === arch(), - ); - - if (!this.modelsManager.isModelOnDisk(model.id)) { - // Download model - taskUtil.setTask({ - id: model.id, - state: 'loading', - name: `Downloading model ${model.name}`, - labels: { - 'model-pulling': model.id, - }, - }); - - await this.downloadModelMain(model.id, model.url, taskUtil); - } else { - taskUtil.setTask({ - id: model.id, - state: 'success', - name: `Model ${model.name} already present on disk`, - labels: { - 'model-pulling': model.id, - }, - }); - } + // build all images, one per container (for a basic sample we should have 2 containers = sample app + model service) + await this.buildImages(filteredContainers, aiConfigFile.path, taskUtil); + } + async buildImages(filteredContainers: ContainerConfig[], configPath: string, taskUtil: RecipeStatusUtils) { filteredContainers.forEach(container => { taskUtil.setTask({ id: container.name, @@ -167,10 +79,10 @@ export class ApplicationManager { }); // Promise all the build images - return Promise.all( + await Promise.all( filteredContainers.map(container => { // We use the parent directory of our configFile as the rootdir, then we append the contextDir provided - const context = path.join(getParentDirectory(configFile), container.contextdir); + const context = path.join(getParentDirectory(configPath), container.contextdir); console.log(`Application Manager using context ${context} for container ${container.name}`); // Ensure the context provided exist otherwise throw an Error @@ -211,7 +123,123 @@ export class ApplicationManager { ); } - downloadModelMain(modelId: string, url: string, taskUtil: RecipeStatusUtils, destFileName?: string): Promise { + filterContainers(aiConfig: AIConfig): ContainerConfig[] { + return aiConfig.application.containers.filter( + container => container.arch === undefined || container.arch === arch(), + ); + } + + async downloadModel(model: ModelInfo, taskUtil: RecipeStatusUtils) { + if (!this.modelsManager.isModelOnDisk(model.id)) { + // Download model + taskUtil.setTask({ + id: model.id, + state: 'loading', + name: `Downloading model ${model.name}`, + labels: { + 'model-pulling': model.id, + }, + }); + + await this.doDownloadModelWrapper(model.id, model.url, taskUtil); + } else { + taskUtil.setTask({ + id: model.id, + state: 'success', + name: `Model ${model.name} already present on disk`, + labels: { + 'model-pulling': model.id, + }, + }); + } + } + + getConfiguration(recipeConfig: string, localFolder: string,taskUtil: RecipeStatusUtils): AIConfigFile { + // Adding loading configuration task + const loadingConfiguration: Task = { + id: 'loading-config', + name: 'Loading configuration', + state: 'loading', + }; + taskUtil.setTask(loadingConfiguration); + + let configFile: string; + if (recipeConfig !== undefined) { + configFile = path.join(localFolder, recipeConfig); + } else { + configFile = path.join(localFolder, CONFIG_FILENAME); + } + + if (!fs.existsSync(configFile)) { + loadingConfiguration.state = 'error'; + taskUtil.setTask(loadingConfiguration); + throw new Error(`The file located at ${configFile} does not exist.`); + } + + // If the user configured the config as a directory we check for "ai-studio.yaml" inside. + if (fs.statSync(configFile).isDirectory()) { + const tmpPath = path.join(configFile, CONFIG_FILENAME); + // If it has the ai-studio.yaml we use it. + if (fs.existsSync(tmpPath)) { + configFile = tmpPath; + } + } + + // Parsing the configuration + console.log(`Reading configuration from ${configFile}.`); + const rawConfiguration = fs.readFileSync(configFile, 'utf-8'); + let aiConfig: AIConfig; + try { + aiConfig = parseYaml(rawConfiguration, arch()); + } catch (err) { + // Mask task as failed + loadingConfiguration.state = 'error'; + taskUtil.setTask(loadingConfiguration); + throw new Error('Cannot load configuration file.'); + } + + // Mark as success. + loadingConfiguration.state = 'success'; + taskUtil.setTask(loadingConfiguration); + return { + aiConfig, + path: configFile, + } + } + + async doCheckout(repository: string, localFolder: string, taskUtil: RecipeStatusUtils) { + // Adding checkout task + const checkoutTask: Task = { + id: 'checkout', + name: 'Checkout repository', + state: 'loading', + labels: { + git: 'checkout', + }, + }; + taskUtil.setTask(checkoutTask); + + // We might already have the repository cloned + if (fs.existsSync(localFolder) && fs.statSync(localFolder).isDirectory()) { + // Update checkout state + checkoutTask.name = 'Checkout repository (cached).'; + checkoutTask.state = 'success'; + } else { + // Create folder + fs.mkdirSync(localFolder, { recursive: true }); + + // Clone the repository + console.log(`Cloning repository ${repository} in ${localFolder}.`); + await this.git.cloneRepository(repository, localFolder); + + // Update checkout state + checkoutTask.state = 'success'; + } + // Update task + taskUtil.setTask(checkoutTask); + } + + doDownloadModelWrapper(modelId: string, url: string, taskUtil: RecipeStatusUtils, destFileName?: string): Promise { return new Promise((resolve, reject) => { const downloadCallback = (result: DownloadModelResult) => { if (result.result) { @@ -229,11 +257,11 @@ export class ApplicationManager { return; } - this.downloadModel(modelId, url, taskUtil, downloadCallback, destFileName); + this.doDownloadModel(modelId, url, taskUtil, downloadCallback, destFileName); }); } - private downloadModel( + private doDownloadModel( modelId: string, url: string, taskUtil: RecipeStatusUtils, @@ -253,7 +281,7 @@ export class ApplicationManager { let progress = 0; https.get(url, resp => { if (resp.headers.location) { - this.downloadModel(modelId, resp.headers.location, taskUtil, callback, destFileName); + this.doDownloadModel(modelId, resp.headers.location, taskUtil, callback, destFileName); return; } else { if (totalFileSize === 0 && resp.headers['content-length']) { diff --git a/packages/backend/src/models/AIConfig.ts b/packages/backend/src/models/AIConfig.ts index bbca2d10e..e0e573cfc 100644 --- a/packages/backend/src/models/AIConfig.ts +++ b/packages/backend/src/models/AIConfig.ts @@ -31,6 +31,11 @@ export interface AIConfig { }; } +export interface AIConfigFile { + aiConfig: AIConfig; + path: string; +} + export function isString(value: unknown): value is string { return (!!value && typeof value === 'string') || value instanceof String; }