diff --git a/packages/backend/src/managers/applicationManager.spec.ts b/packages/backend/src/managers/applicationManager.spec.ts index 5c7b79087..db061192a 100644 --- a/packages/backend/src/managers/applicationManager.spec.ts +++ b/packages/backend/src/managers/applicationManager.spec.ts @@ -1,4 +1,5 @@ import { type MockInstance, describe, expect, test, vi, beforeEach } from 'vitest'; +import type { ImageInfo, PodInfo } from './applicationManager'; import { ApplicationManager } from './applicationManager'; import type { RecipeStatusRegistry } from '../registries/RecipeStatusRegistry'; import type { GitManager } from './gitManager'; @@ -6,44 +7,60 @@ import os from 'os'; import fs from 'node:fs'; import type { Recipe } from '@shared/src/models/IRecipe'; import type { ModelInfo } from '@shared/src/models/IModelInfo'; -import type { RecipeStatusUtils } from '../utils/recipeStatusUtils'; +import { RecipeStatusUtils } from '../utils/recipeStatusUtils'; import type { ModelsManager } from './modelsManager'; +import path from 'node:path'; +import type { AIConfig, ContainerConfig } from '../models/AIConfig'; +import * as portsUtils from '../utils/ports'; const mocks = vi.hoisted(() => { return { parseYamlMock: vi.fn(), builImageMock: vi.fn(), + listImagesMock: vi.fn(), + getImageInspectMock: vi.fn(), + createPodMock: vi.fn(), + createContainerMock: vi.fn(), + replicatePodmanContainerMock: vi.fn(), }; }); - vi.mock('../models/AIConfig', () => ({ parseYaml: mocks.parseYamlMock, })); - vi.mock('@podman-desktop/api', () => ({ containerEngine: { buildImage: mocks.builImageMock, + listImages: mocks.listImagesMock, + getImageInspect: mocks.getImageInspectMock, + createPod: mocks.createPodMock, + createContainer: mocks.createContainerMock, + replicatePodmanContainer: mocks.replicatePodmanContainerMock, }, })); - +let setTaskMock: MockInstance; +let taskUtils: RecipeStatusUtils; +let setTaskStateMock: MockInstance; beforeEach(() => { vi.resetAllMocks(); + taskUtils = new RecipeStatusUtils('recipe', { + setStatus: vi.fn(), + } as unknown as RecipeStatusRegistry); + setTaskMock = vi.spyOn(taskUtils, 'setTask'); + setTaskStateMock = vi.spyOn(taskUtils, 'setTaskState'); }); - describe('pullApplication', () => { interface mockForPullApplicationOptions { recipeFolderExists: boolean; } - const setStatusMock = vi.fn(); const cloneRepositoryMock = vi.fn(); const isModelOnDiskMock = vi.fn(); + const getLocalModelPathMock = vi.fn(); let manager: ApplicationManager; - let downloadModelMainSpy: MockInstance< + let doDownloadModelWrapperSpy: MockInstance< [modelId: string, url: string, taskUtil: RecipeStatusUtils, destFileName?: string], Promise >; - function mockForPullApplication(options: mockForPullApplicationOptions) { vi.spyOn(os, 'homedir').mockReturnValue('/home/user'); vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined); @@ -83,7 +100,27 @@ describe('pullApplication', () => { }, }); mocks.builImageMock.mockResolvedValue(undefined); - + mocks.listImagesMock.mockResolvedValue([ + { + RepoTags: ['container1:latest'], + engineId: 'engine', + Id: 'id1', + }, + ]); + mocks.getImageInspectMock.mockResolvedValue({ + Config: { + ExposedPorts: { + '8080': '8080', + }, + }, + }); + mocks.createPodMock.mockResolvedValue({ + engineId: 'engine', + Id: 'id', + }); + mocks.createContainerMock.mockResolvedValue({ + id: 'id', + }); manager = new ApplicationManager( '/home/user/aistudio', { @@ -94,19 +131,17 @@ describe('pullApplication', () => { } as unknown as RecipeStatusRegistry, { isModelOnDisk: isModelOnDiskMock, + getLocalModelPath: getLocalModelPathMock, } as unknown as ModelsManager, ); - - downloadModelMainSpy = vi.spyOn(manager, 'downloadModelMain'); - downloadModelMainSpy.mockResolvedValue(''); + doDownloadModelWrapperSpy = vi.spyOn(manager, 'doDownloadModelWrapper'); + doDownloadModelWrapperSpy.mockResolvedValue('path'); } - test('pullApplication should clone repository and call downloadModelMain and buildImage', async () => { mockForPullApplication({ recipeFolderExists: false, }); isModelOnDiskMock.mockReturnValue(false); - const recipe: Recipe = { id: 'recipe1', name: 'Recipe 1', @@ -125,23 +160,20 @@ describe('pullApplication', () => { registry: '', url: '', }; - await manager.pullApplication(recipe, model); if (process.platform === 'win32') { expect(cloneRepositoryMock).toHaveBeenNthCalledWith(1, 'repo', '\\home\\user\\aistudio\\recipe1'); } else { expect(cloneRepositoryMock).toHaveBeenNthCalledWith(1, 'repo', '/home/user/aistudio/recipe1'); } - expect(downloadModelMainSpy).toHaveBeenCalledOnce(); + expect(doDownloadModelWrapperSpy).toHaveBeenCalledOnce(); expect(mocks.builImageMock).toHaveBeenCalledOnce(); }); - test('pullApplication should not clone repository if folder already exists locally', async () => { mockForPullApplication({ recipeFolderExists: true, }); isModelOnDiskMock.mockReturnValue(false); - const recipe: Recipe = { id: 'recipe1', name: 'Recipe 1', @@ -160,16 +192,15 @@ describe('pullApplication', () => { registry: '', url: '', }; - await manager.pullApplication(recipe, model); expect(cloneRepositoryMock).not.toHaveBeenCalled(); }); - test('pullApplication should not download model if already on disk', async () => { mockForPullApplication({ recipeFolderExists: true, }); isModelOnDiskMock.mockReturnValue(true); + getLocalModelPathMock.mockReturnValue('path'); const recipe: Recipe = { id: 'recipe1', name: 'Recipe 1', @@ -188,10 +219,9 @@ describe('pullApplication', () => { registry: '', url: '', }; - await manager.pullApplication(recipe, model); expect(cloneRepositoryMock).not.toHaveBeenCalled(); - expect(downloadModelMainSpy).not.toHaveBeenCalled(); + expect(doDownloadModelWrapperSpy).not.toHaveBeenCalled(); }); test('pullApplication should mark the loading config as error if not container are found', async () => { @@ -227,6 +257,456 @@ describe('pullApplication', () => { await expect(manager.pullApplication(recipe, model)).rejects.toThrowError('No containers available.'); expect(cloneRepositoryMock).not.toHaveBeenCalled(); - expect(downloadModelMainSpy).not.toHaveBeenCalled(); + expect(doDownloadModelWrapperSpy).not.toHaveBeenCalled(); + }); +}); +describe('doCheckout', () => { + test('clone repo if not present locally', async () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(false); + vi.spyOn(fs, 'mkdirSync'); + const cloneRepositoryMock = vi.fn(); + const manager = new ApplicationManager( + '/home/user/aistudio', + { + cloneRepository: cloneRepositoryMock, + } as unknown as GitManager, + {} as unknown as RecipeStatusRegistry, + {} as unknown as ModelsManager, + ); + await manager.doCheckout('repo', 'folder', taskUtils); + expect(cloneRepositoryMock).toBeCalledWith('repo', 'folder'); + expect(setTaskMock).toHaveBeenLastCalledWith({ + id: 'checkout', + name: 'Checkout repository', + state: 'success', + labels: { + git: 'checkout', + }, + }); + }); + test('do not clone repo if already present locally', async () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + const stats = { + isDirectory: vi.fn().mockReturnValue(true), + } as unknown as fs.Stats; + vi.spyOn(fs, 'statSync').mockReturnValue(stats); + const mkdirSyncMock = vi.spyOn(fs, 'mkdirSync'); + const cloneRepositoryMock = vi.fn(); + const manager = new ApplicationManager( + '/home/user/aistudio', + { + cloneRepository: cloneRepositoryMock, + } as unknown as GitManager, + {} as unknown as RecipeStatusRegistry, + {} as unknown as ModelsManager, + ); + await manager.doCheckout('repo', 'folder', taskUtils); + expect(mkdirSyncMock).not.toHaveBeenCalled(); + expect(cloneRepositoryMock).not.toHaveBeenCalled(); + expect(setTaskMock).toHaveBeenLastCalledWith({ + id: 'checkout', + name: 'Checkout repository (cached).', + state: 'success', + labels: { + git: 'checkout', + }, + }); + }); +}); + +describe('getConfiguration', () => { + test('throws error if config file do not exists', async () => { + const manager = new ApplicationManager( + '/home/user/aistudio', + {} as unknown as GitManager, + {} as unknown as RecipeStatusRegistry, + {} as unknown as ModelsManager, + ); + vi.spyOn(fs, 'existsSync').mockReturnValue(false); + expect(() => manager.getConfiguration('config', 'local')).toThrowError( + `The file located at ${path.join('local', 'config')} does not exist.`, + ); + }); + + test('return AIConfigFile', async () => { + const manager = new ApplicationManager( + '/home/user/aistudio', + {} as unknown as GitManager, + {} as unknown as RecipeStatusRegistry, + {} as unknown as ModelsManager, + ); + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + const stats = { + isDirectory: vi.fn().mockReturnValue(false), + } as unknown as fs.Stats; + vi.spyOn(fs, 'statSync').mockReturnValue(stats); + vi.spyOn(fs, 'readFileSync').mockReturnValue(''); + const aiConfig = { + application: { + containers: [ + { + name: 'container1', + contextdir: 'contextdir1', + containerfile: 'Containerfile', + }, + ], + }, + }; + mocks.parseYamlMock.mockReturnValue(aiConfig); + + const result = manager.getConfiguration('config', 'local'); + expect(result.path).toEqual(path.join('local', 'config')); + expect(result.aiConfig).toEqual(aiConfig); + }); +}); + +describe('downloadModel', () => { + test('download model if not already on disk', async () => { + const isModelOnDiskMock = vi.fn().mockReturnValue(false); + const manager = new ApplicationManager( + '/home/user/aistudio', + {} as unknown as GitManager, + {} as unknown as RecipeStatusRegistry, + { isModelOnDisk: isModelOnDiskMock } as unknown as ModelsManager, + ); + const doDownloadModelWrapperMock = vi + .spyOn(manager, 'doDownloadModelWrapper') + .mockImplementation((_modelId: string, _url: string, _taskUtil: RecipeStatusUtils, _destFileName?: string) => { + return Promise.resolve(''); + }); + await manager.downloadModel( + { + id: 'id', + url: 'url', + name: 'name', + } as ModelInfo, + taskUtils, + ); + expect(doDownloadModelWrapperMock).toBeCalledWith('id', 'url', taskUtils); + expect(setTaskMock).toHaveBeenLastCalledWith({ + id: 'id', + name: 'Downloading model name', + labels: { + 'model-pulling': 'id', + }, + state: 'loading', + }); + }); + test('retrieve model path if already on disk', async () => { + const isModelOnDiskMock = vi.fn().mockReturnValue(true); + const getLocalModelPathMock = vi.fn(); + const manager = new ApplicationManager( + '/home/user/aistudio', + {} as unknown as GitManager, + {} as unknown as RecipeStatusRegistry, + { + isModelOnDisk: isModelOnDiskMock, + getLocalModelPath: getLocalModelPathMock, + } as unknown as ModelsManager, + ); + await manager.downloadModel( + { + id: 'id', + url: 'url', + name: 'name', + } as ModelInfo, + taskUtils, + ); + expect(getLocalModelPathMock).toBeCalledWith('id'); + expect(setTaskMock).toHaveBeenLastCalledWith({ + id: 'id', + name: 'Model name already present on disk', + labels: { + 'model-pulling': 'id', + }, + state: 'success', + }); + }); +}); + +describe('filterContainers', () => { + test('return empty array when no container fit the system', () => { + const aiConfig: AIConfig = { + application: { + containers: [ + { + name: 'container2', + contextdir: 'contextdir2', + containerfile: 'Containerfile', + arch: 'arm64', + modelService: false, + }, + ], + }, + }; + Object.defineProperty(process, 'arch', { + value: 'amd64', + }); + const manager = new ApplicationManager( + '/home/user/aistudio', + {} as unknown as GitManager, + {} as unknown as RecipeStatusRegistry, + {} as unknown as ModelsManager, + ); + const containers = manager.filterContainers(aiConfig); + expect(containers.length).toBe(0); + }); + test('return one container when only one fit the system', () => { + const aiConfig: AIConfig = { + application: { + containers: [ + { + name: 'container1', + contextdir: 'contextdir1', + containerfile: 'Containerfile', + arch: 'amd64', + modelService: false, + }, + { + name: 'container2', + contextdir: 'contextdir2', + containerfile: 'Containerfile', + arch: 'arm64', + modelService: false, + }, + ], + }, + }; + Object.defineProperty(process, 'arch', { + value: 'amd64', + }); + const manager = new ApplicationManager( + '/home/user/aistudio', + {} as unknown as GitManager, + {} as unknown as RecipeStatusRegistry, + {} as unknown as ModelsManager, + ); + const containers = manager.filterContainers(aiConfig); + expect(containers.length).toBe(1); + expect(containers[0].name).equal('container1'); + }); + test('return 2 containers when two fit the system', () => { + const containerConfig: ContainerConfig[] = [ + { + name: 'container1', + contextdir: 'contextdir1', + containerfile: 'Containerfile', + arch: 'amd64', + modelService: false, + }, + { + name: 'container2', + contextdir: 'contextdir2', + containerfile: 'Containerfile', + arch: 'arm64', + modelService: false, + }, + { + name: 'container3', + contextdir: 'contextdir3', + containerfile: 'Containerfile', + arch: 'amd64', + modelService: false, + }, + ]; + const aiConfig: AIConfig = { + application: { + containers: containerConfig, + }, + }; + Object.defineProperty(process, 'arch', { + value: 'amd64', + }); + const manager = new ApplicationManager( + '/home/user/aistudio', + {} as unknown as GitManager, + {} as unknown as RecipeStatusRegistry, + {} as unknown as ModelsManager, + ); + const containers = manager.filterContainers(aiConfig); + expect(containers.length).toBe(2); + expect(containers[0].name).equal('container1'); + expect(containers[1].name).equal('container3'); + }); +}); + +describe('getRandomName', () => { + test('return base name plus random string', () => { + const manager = new ApplicationManager( + '/home/user/aistudio', + {} as unknown as GitManager, + {} as unknown as RecipeStatusRegistry, + {} as unknown as ModelsManager, + ); + const randomName = manager.getRandomName('base'); + expect(randomName).not.equal('base'); + expect(randomName.length).toBeGreaterThan(4); + }); + test('return random string when base is empty', () => { + const manager = new ApplicationManager( + '/home/user/aistudio', + {} as unknown as GitManager, + {} as unknown as RecipeStatusRegistry, + {} as unknown as ModelsManager, + ); + const randomName = manager.getRandomName(''); + expect(randomName.length).toBeGreaterThan(0); + }); +}); + +describe('buildImages', () => { + const containers: ContainerConfig[] = [ + { + name: 'container1', + contextdir: 'contextdir1', + containerfile: 'Containerfile', + arch: 'amd64', + modelService: false, + }, + ]; + const manager = new ApplicationManager( + '/home/user/aistudio', + {} as unknown as GitManager, + {} as unknown as RecipeStatusRegistry, + {} as unknown as ModelsManager, + ); + test('setTaskState should be called with error if context does not exist', async () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(false); + mocks.listImagesMock.mockRejectedValue([]); + await expect(manager.buildImages(containers, 'config', taskUtils)).rejects.toThrow( + 'Context configured does not exist.', + ); + }); + test('setTaskState should be called with error if buildImage executon fails', async () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + mocks.builImageMock.mockRejectedValue('error'); + mocks.listImagesMock.mockRejectedValue([]); + await expect(manager.buildImages(containers, 'config', taskUtils)).rejects.toThrow( + 'Something went wrong while building the image: error', + ); + expect(setTaskStateMock).toBeCalledWith('container1', 'error'); + }); + test('setTaskState should be called with error if unable to find the image after built', async () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + mocks.builImageMock.mockResolvedValue({}); + mocks.listImagesMock.mockResolvedValue([]); + await expect(manager.buildImages(containers, 'config', taskUtils)).rejects.toThrow( + 'no image found for container1:latest', + ); + expect(setTaskStateMock).toBeCalledWith('container1', 'error'); + }); + test('succeed if building image do not fail', async () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + mocks.builImageMock.mockResolvedValue({}); + mocks.listImagesMock.mockResolvedValue([ + { + RepoTags: ['container1:latest'], + engineId: 'engine', + Id: 'id1', + }, + ]); + mocks.getImageInspectMock.mockResolvedValue({ + Config: { + ExposedPorts: { + '8080': '8080', + }, + }, + }); + const imageInfoList = await manager.buildImages(containers, 'config', taskUtils); + expect(setTaskStateMock).toBeCalledWith('container1', 'success'); + expect(imageInfoList.length).toBe(1); + expect(imageInfoList[0].ports.length).toBe(1); + expect(imageInfoList[0].ports[0]).equals('8080'); + }); +}); + +describe('createPod', async () => { + const imageInfo1: ImageInfo = { + id: 'id', + appName: 'appName', + modelService: false, + ports: ['8080'], + }; + const imageInfo2: ImageInfo = { + id: 'id2', + appName: 'appName2', + modelService: true, + ports: ['8082'], + }; + const manager = new ApplicationManager( + '/home/user/aistudio', + {} as unknown as GitManager, + {} as unknown as RecipeStatusRegistry, + {} as unknown as ModelsManager, + ); + test('throw an error if there is no sample image', async () => { + const images = [imageInfo2]; + await expect(manager.createPod(images)).rejects.toThrowError('no sample app found'); + }); + test('call createPod with sample app exposed port', async () => { + const images = [imageInfo1, imageInfo2]; + vi.spyOn(manager, 'getRandomName').mockReturnValue('name'); + vi.spyOn(portsUtils, 'getPortsInfo').mockResolvedValue('9000'); + await manager.createPod(images); + expect(mocks.createPodMock).toBeCalledWith({ + name: 'name', + portmappings: [ + { + container_port: 8080, + host_port: 9000, + host_ip: '', + protocol: '', + range: 1, + }, + ], + }); + }); +}); + +describe('createApplicationPod', () => { + const imageInfo1: ImageInfo = { + id: 'id', + appName: 'appName', + modelService: false, + ports: ['8080'], + }; + const imageInfo2: ImageInfo = { + id: 'id2', + appName: 'appName2', + modelService: true, + ports: ['8082'], + }; + const manager = new ApplicationManager( + '/home/user/aistudio', + {} as unknown as GitManager, + {} as unknown as RecipeStatusRegistry, + {} as unknown as ModelsManager, + ); + const images = [imageInfo1, imageInfo2]; + test('throw if createPod fails', async () => { + vi.spyOn(manager, 'createPod').mockRejectedValue('error createPod'); + await expect(manager.createApplicationPod(images, 'path', taskUtils)).rejects.toThrowError('error createPod'); + expect(setTaskMock).toBeCalledWith({ + id: 'fake-pod-id', + state: 'error', + name: 'Creating application', + }); + }); + test('call createAndAddContainersToPod after pod is created', async () => { + const pod: PodInfo = { + engineId: 'engine', + Id: 'id', + }; + vi.spyOn(manager, 'createPod').mockResolvedValue(pod); + const createAndAddContainersToPodMock = vi + .spyOn(manager, 'createAndAddContainersToPod') + .mockImplementation((_pod: PodInfo, _images: ImageInfo[], _modelPath: string) => Promise.resolve()); + await manager.createApplicationPod(images, 'path', taskUtils); + expect(createAndAddContainersToPodMock).toBeCalledWith(pod, images, 'path'); + expect(setTaskMock).toBeCalledWith({ + id: 'id', + state: 'success', + name: 'Creating application', + }); }); }); diff --git a/packages/backend/src/managers/applicationManager.ts b/packages/backend/src/managers/applicationManager.ts index c758bb59e..c72a12dbe 100644 --- a/packages/backend/src/managers/applicationManager.ts +++ b/packages/backend/src/managers/applicationManager.ts @@ -22,15 +22,16 @@ import type { GitManager } from './gitManager'; import fs from 'fs'; import * as https from 'node:https'; import * as path from 'node:path'; -import { containerEngine } from '@podman-desktop/api'; +import { type PodCreatePortOptions, 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'; import { getParentDirectory } from '../utils/pathUtils'; import type { ModelInfo } from '@shared/src/models/IModelInfo'; import type { ModelsManager } from './modelsManager'; +import { getPortsInfo } from '../utils/ports'; export const CONFIG_FILENAME = 'ai-studio.yaml'; @@ -39,6 +40,23 @@ interface DownloadModelResult { error?: string; } +interface AIContainers { + aiConfigFile: AIConfigFile; + containers: ContainerConfig[]; +} + +export interface PodInfo { + engineId: string; + Id: string; +} + +export interface ImageInfo { + id: string; + modelService: boolean; + ports: string[]; + appName: string; +} + export class ApplicationManager { constructor( private appUserDirectory: string, @@ -53,119 +71,153 @@ 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); + // clone the recipe repository on the local folder + await this.doCheckout(recipe.repository, localFolder, taskUtil); - // 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 }); + // load and parse the recipe configuration file and filter containers based on architecture, gpu accelerator + // and backend (that define which model supports) + const configAndFilteredContainers = this.getConfigAndFilterContainers(recipe.config, localFolder, taskUtil); - // Clone the repository - console.log(`Cloning repository ${recipe.repository} in ${localFolder}.`); - await this.git.cloneRepository(recipe.repository, localFolder); + // get model by downloading it or retrieving locally + const modelPath = await this.downloadModel(model, taskUtil); - // Update checkout state - checkoutTask.state = 'success'; - } - // Update task - taskUtil.setTask(checkoutTask); + // 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, + configAndFilteredContainers.aiConfigFile.path, + taskUtil, + ); - // Adding loading configuration task - const loadingConfiguration: Task = { - id: 'loading-config', - name: 'Loading configuration', - state: 'loading', - }; - taskUtil.setTask(loadingConfiguration); + // create a pod containing all the containers to run the application + await this.createApplicationPod(images, modelPath, taskUtil); + } - let configFile: string; - if (recipe.config !== undefined) { - configFile = path.join(localFolder, recipe.config); - } else { - configFile = path.join(localFolder, CONFIG_FILENAME); + async createApplicationPod(images: ImageInfo[], modelPath: string, taskUtil: RecipeStatusUtils) { + // create empty pod + let pod: PodInfo; + try { + pod = await this.createPod(images); + } catch (e) { + console.error('error when creating pod'); + taskUtil.setTask({ + id: 'fake-pod-id', + state: 'error', + name: 'Creating application', + }); + throw e; } - if (!fs.existsSync(configFile)) { - loadingConfiguration.state = 'error'; - taskUtil.setTask(loadingConfiguration); - throw new Error(`The file located at ${configFile} does not exist.`); - } + taskUtil.setTask({ + id: pod.Id, + state: 'loading', + name: `Creating application`, + }); - // 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; - } - } + await this.createAndAddContainersToPod(pod, images, modelPath); - // 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.'); - } + taskUtil.setTask({ + id: pod.Id, + state: 'success', + name: `Creating application`, + }); + } - // Filter the containers based on architecture - const filteredContainers = aiConfig.application.containers.filter( - container => container.arch === undefined || container.arch === arch(), + async createAndAddContainersToPod(pod: PodInfo, images: ImageInfo[], modelPath: string) { + await Promise.all( + images.map(async image => { + let hostConfig: unknown; + let envs: string[] = []; + // if it's a model service we mount the model as a volume + if (image.modelService) { + const modelName = path.basename(modelPath); + hostConfig = { + AutoRemove: true, + Mounts: [ + { + Target: `/${modelName}`, + Source: modelPath, + Type: 'bind', + }, + ], + }; + envs = [`MODEL_PATH=/${modelName}`]; + } else { + hostConfig = { + AutoRemove: true, + }; + // TODO: remove static port + const modelService = images.find(image => image.modelService); + if (modelService && modelService.ports.length > 0) { + envs = [`MODEL_ENDPOINT=http://localhost:${modelService.ports[0]}`]; + } + } + const createdContainer = await containerEngine + .createContainer(pod.engineId, { + Image: image.id, + Detach: true, + HostConfig: hostConfig, + Env: envs, + start: false, + }) + .catch((e: unknown) => console.error(e)); + + // now, for each container, put it in the pod + if (createdContainer) { + try { + await containerEngine.replicatePodmanContainer( + { + id: createdContainer.id, + engineId: pod.engineId, + }, + { engineId: pod.engineId }, + { pod: pod.Id, name: this.getRandomName(`${image.appName}-podified`) }, + ); + } catch (error) { + console.error(error); + } + } + }), ); + } - if (filteredContainers.length > 0) { - // Mark as success. - loadingConfiguration.state = 'success'; - taskUtil.setTask(loadingConfiguration); - } else { - // Mark as failure. - loadingConfiguration.state = 'error'; - taskUtil.setTask(loadingConfiguration); - throw new Error('No containers available.'); + async createPod(images: ImageInfo[]): Promise { + // find the exposed port of the sample app so we can open its ports on the new pod + const sampleAppImageInfo = images.find(image => !image.modelService); + if (!sampleAppImageInfo) { + console.error('no image found'); + throw new Error('no sample app found'); } - 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, - }, + const portmappings: PodCreatePortOptions[] = []; + // N.B: it may not work with ranges + for (const exposed of sampleAppImageInfo.ports) { + const localPorts = await getPortsInfo(exposed); + portmappings.push({ + container_port: parseInt(exposed), + host_port: parseInt(localPorts), + host_ip: '', + protocol: '', + range: 1, }); } - filteredContainers.forEach(container => { + // create new pod + return await containerEngine.createPod({ + name: this.getRandomName(`pod-${sampleAppImageInfo.appName}`), + portmappings: portmappings, + }); + } + + getRandomName(base: string): string { + return `${base ?? ''}-${new Date().getTime()}`; + } + + async buildImages( + containers: ContainerConfig[], + configPath: string, + taskUtil: RecipeStatusUtils, + ): Promise { + containers.forEach(container => { taskUtil.setTask({ id: container.name, state: 'loading', @@ -173,11 +225,13 @@ export class ApplicationManager { }); }); + const imageInfoList: ImageInfo[] = []; + // Promise all the build images - return Promise.all( - filteredContainers.map(container => { + await Promise.all( + containers.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 @@ -187,8 +241,6 @@ export class ApplicationManager { throw new Error('Context configured does not exist.'); } - let isErrored = false; - const buildOptions = { containerFile: container.containerfile, tag: `${container.name}:latest`, @@ -202,10 +254,6 @@ export class ApplicationManager { if (event === 'error' || (event === 'finish' && data !== '')) { console.error('Something went wrong while building the image: ', data); taskUtil.setTaskState(container.name, 'error'); - isErrored = true; - } - if (event === 'finish' && !isErrored) { - taskUtil.setTaskState(container.name, 'success'); } }, buildOptions, @@ -213,17 +261,198 @@ export class ApplicationManager { .catch((err: unknown) => { console.error('Something went wrong while building the image: ', err); taskUtil.setTaskState(container.name, 'error'); + throw new Error(`Something went wrong while building the image: ${String(err)}`); }); }), ); + + // after image are built we return their data + const images = await containerEngine.listImages(); + await Promise.all( + containers.map(async container => { + const image = images.find(im => { + return im.RepoTags?.some(tag => tag.endsWith(`${container.name}:latest`)); + }); + + if (!image) { + console.error('no image found'); + taskUtil.setTaskState(container.name, 'error'); + throw new Error(`no image found for ${container.name}:latest`); + } + + const imageInspectInfo = await containerEngine.getImageInspect(image.engineId, image.Id); + const exposedPorts = Array.from(Object.keys(imageInspectInfo?.Config?.ExposedPorts || {})).map(port => { + if (port.endsWith('/tcp') || port.endsWith('/udp')) { + return port.substring(0, port.length - 4); + } + return port; + }); + + imageInfoList.push({ + id: image.Id, + modelService: container.modelService, + ports: exposedPorts, + appName: container.name, + }); + + taskUtil.setTaskState(container.name, 'success'); + }), + ); + + return imageInfoList; + } + + getConfigAndFilterContainers(recipeConfig: string, localFolder: string, taskUtil: RecipeStatusUtils): AIContainers { + // Adding loading configuration task + const loadingConfiguration: Task = { + id: 'loading-config', + name: 'Loading configuration', + state: 'loading', + }; + taskUtil.setTask(loadingConfiguration); + + let aiConfigFile: AIConfigFile; + try { + // load and parse the recipe configuration file + aiConfigFile = this.getConfiguration(recipeConfig, localFolder); + } catch (e) { + loadingConfiguration.state = 'error'; + taskUtil.setTask(loadingConfiguration); + throw e; + } + + // filter the containers based on architecture, gpu accelerator and backend (that define which model supports) + const filteredContainers: ContainerConfig[] = this.filterContainers(aiConfigFile.aiConfig); + if (filteredContainers.length > 0) { + // Mark as success. + loadingConfiguration.state = 'success'; + taskUtil.setTask(loadingConfiguration); + } else { + // Mark as failure. + loadingConfiguration.state = 'error'; + taskUtil.setTask(loadingConfiguration); + throw new Error('No containers available.'); + } + + return { + aiConfigFile: aiConfigFile, + containers: filteredContainers, + }; + } + + filterContainers(aiConfig: AIConfig): ContainerConfig[] { + return aiConfig.application.containers.filter( + container => container.arch === undefined || container.arch === arch(), + ); } - downloadModelMain(modelId: string, url: string, taskUtil: RecipeStatusUtils, destFileName?: string): Promise { + 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, + }, + }); + + return 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, + }, + }); + return this.modelsManager.getLocalModelPath(model.id); + } + } + + getConfiguration(recipeConfig: string, localFolder: string): AIConfigFile { + let configFile: string; + if (recipeConfig !== undefined) { + configFile = path.join(localFolder, recipeConfig); + } else { + configFile = path.join(localFolder, CONFIG_FILENAME); + } + + if (!fs.existsSync(configFile)) { + 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) { + throw new Error('Cannot load configuration file.'); + } + + // Mark as success. + 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) { taskUtil.setTaskState(modelId, 'success'); - resolve(''); + resolve(destFileName); } else { taskUtil.setTaskState(modelId, 'error'); reject(result.error); @@ -236,11 +465,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, @@ -260,7 +489,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/managers/modelsManager.spec.ts b/packages/backend/src/managers/modelsManager.spec.ts index f57b7ef4f..47f1faf55 100644 --- a/packages/backend/src/managers/modelsManager.spec.ts +++ b/packages/backend/src/managers/modelsManager.spec.ts @@ -11,6 +11,24 @@ beforeEach(() => { vi.resetAllMocks(); }); +const dirent = [ + { + isDirectory: () => true, + path: '/home/user/appstudio-dir', + name: 'model-id-1', + }, + { + isDirectory: () => true, + path: '/home/user/appstudio-dir', + name: 'model-id-2', + }, + { + isDirectory: () => false, + path: '/home/user/appstudio-dir', + name: 'other-file-should-be-ignored.txt', + }, +] as fs.Dirent[]; + function mockFiles(now: Date) { vi.spyOn(os, 'homedir').mockReturnValue('/home/user'); const existsSyncSpy = vi.spyOn(fs, 'existsSync'); @@ -36,23 +54,7 @@ function mockFiles(now: Date) { const base = path.basename(dir); return [base + '-model']; } else { - return [ - { - isDirectory: () => true, - path: '/home/user/appstudio-dir', - name: 'model-id-1', - }, - { - isDirectory: () => true, - path: '/home/user/appstudio-dir', - name: 'model-id-2', - }, - { - isDirectory: () => false, - path: '/home/user/appstudio-dir', - name: 'other-file-should-be-ignored.txt', - }, - ] as fs.Dirent[]; + return dirent; } }); } @@ -68,12 +70,14 @@ test('getLocalModelsFromDisk should get models in local directory', () => { file: 'model-id-1-model', size: 32000, creation: now, + path: path.resolve(dirent[0].path, dirent[0].name, 'model-id-1-model'), }, { id: 'model-id-2', file: 'model-id-2-model', size: 32000, creation: now, + path: path.resolve(dirent[1].path, dirent[1].name, 'model-id-2-model'), }, ]); }); @@ -133,6 +137,7 @@ test('loadLocalModels should post a message with the message on disk and on cata file: 'model-id-1-model', id: 'model-id-1', size: 32000, + path: path.resolve(dirent[0].path, dirent[0].name, 'model-id-1-model'), }, id: 'model-id-1', }, diff --git a/packages/backend/src/managers/modelsManager.ts b/packages/backend/src/managers/modelsManager.ts index 61744deb6..b2a266222 100644 --- a/packages/backend/src/managers/modelsManager.ts +++ b/packages/backend/src/managers/modelsManager.ts @@ -56,10 +56,12 @@ export class ModelsManager { continue; } const modelFile = modelEntries[0]; - const info = fs.statSync(path.resolve(d.path, d.name, modelFile)); + const fullPath = path.resolve(d.path, d.name, modelFile); + const info = fs.statSync(fullPath); result.set(d.name, { id: d.name, file: modelFile, + path: fullPath, size: info.size, creation: info.mtime, }); 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; } diff --git a/packages/backend/src/utils/ports.ts b/packages/backend/src/utils/ports.ts index 91d12473a..9a5c33b00 100644 --- a/packages/backend/src/utils/ports.ts +++ b/packages/backend/src/utils/ports.ts @@ -64,3 +64,78 @@ export function isFreePort(port: number): Promise { .listen(port, '127.0.0.1'), ); } + +export async function getPortsInfo(portDescriptor: string): Promise { + // check if portDescriptor is a range of ports + if (portDescriptor.includes('-')) { + return await getPortRange(portDescriptor); + } else { + const localPort = await getPort(portDescriptor); + if (!localPort) { + return undefined; + } + return `${localPort}`; + } +} + +/** + * return a range of the same length as portDescriptor containing free ports + * undefined if the portDescriptor range is not valid + * e.g 5000:5001 -> 9000:9001 + */ +async function getPortRange(portDescriptor: string): Promise { + const rangeValues = getStartEndRange(portDescriptor); + if (!rangeValues) { + return Promise.resolve(undefined); + } + + const rangeSize = rangeValues.endRange + 1 - rangeValues.startRange; + try { + // if free port range fails, return undefined + return await getFreePortRange(rangeSize); + } catch (e) { + console.error(e); + return undefined; + } +} + +async function getPort(portDescriptor: string): Promise { + let port: number; + if (portDescriptor.endsWith('/tcp') || portDescriptor.endsWith('/udp')) { + port = parseInt(portDescriptor.substring(0, portDescriptor.length - 4)); + } else { + port = parseInt(portDescriptor); + } + // invalid port + if (isNaN(port)) { + return Promise.resolve(undefined); + } + try { + // if getFreePort fails, it returns undefined + return await getFreePort(port); + } catch (e) { + console.error(e); + return undefined; + } +} + +function getStartEndRange(range: string) { + if (range.endsWith('/tcp') || range.endsWith('/udp')) { + range = range.substring(0, range.length - 4); + } + + const rangeValues = range.split('-'); + if (rangeValues.length !== 2) { + return undefined; + } + const startRange = parseInt(rangeValues[0]); + const endRange = parseInt(rangeValues[1]); + + if (isNaN(startRange) || isNaN(endRange)) { + return undefined; + } + return { + startRange, + endRange, + }; +} diff --git a/packages/frontend/src/lib/table/model/ModelColumnCreation.spec.ts b/packages/frontend/src/lib/table/model/ModelColumnCreation.spec.ts index 3bd96ddb3..5e754ddd8 100644 --- a/packages/frontend/src/lib/table/model/ModelColumnCreation.spec.ts +++ b/packages/frontend/src/lib/table/model/ModelColumnCreation.spec.ts @@ -40,6 +40,7 @@ test('Expect simple column styling', async () => { file: 'file', creation: d, size: 1000, + path: 'path', }, }; render(ModelColumnCreation, { object }); diff --git a/packages/frontend/src/lib/table/model/ModelColumnSize.spec.ts b/packages/frontend/src/lib/table/model/ModelColumnSize.spec.ts index ef383aaff..c5825769b 100644 --- a/packages/frontend/src/lib/table/model/ModelColumnSize.spec.ts +++ b/packages/frontend/src/lib/table/model/ModelColumnSize.spec.ts @@ -37,6 +37,7 @@ test('Expect simple column styling', async () => { file: 'file', creation: new Date(), size: 1000, + path: 'path', }, }; render(ModelColumnSize, { object }); diff --git a/packages/shared/src/models/ILocalModelInfo.ts b/packages/shared/src/models/ILocalModelInfo.ts index 9179c318b..ecdeb88b0 100644 --- a/packages/shared/src/models/ILocalModelInfo.ts +++ b/packages/shared/src/models/ILocalModelInfo.ts @@ -1,6 +1,7 @@ export interface LocalModelInfo { id: string; file: string; + path: string; size: number; creation: Date; }