diff --git a/packages/backend/src/managers/applicationManager.spec.ts b/packages/backend/src/managers/applicationManager.spec.ts index 9c3ac2084..acdda7ddc 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,8 +7,11 @@ 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 { @@ -20,11 +24,9 @@ const mocks = vi.hoisted(() => { replicatePodmanContainerMock: vi.fn(), }; }); - vi.mock('../models/AIConfig', () => ({ parseYaml: mocks.parseYamlMock, })); - vi.mock('@podman-desktop/api', () => ({ containerEngine: { buildImage: mocks.builImageMock, @@ -35,16 +37,21 @@ vi.mock('@podman-desktop/api', () => ({ 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(); @@ -54,7 +61,6 @@ describe('pullApplication', () => { [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); @@ -115,7 +121,6 @@ describe('pullApplication', () => { mocks.createContainerMock.mockResolvedValue({ id: 'id', }); - manager = new ApplicationManager( '/home/user/aistudio', { @@ -129,17 +134,14 @@ describe('pullApplication', () => { getLocalModelPath: getLocalModelPathMock, } as unknown as ModelsManager, ); - 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', @@ -158,7 +160,6 @@ describe('pullApplication', () => { registry: '', url: '', }; - await manager.pullApplication(recipe, model); if (process.platform === 'win32') { expect(cloneRepositoryMock).toHaveBeenNthCalledWith(1, 'repo', '\\home\\user\\aistudio\\recipe1'); @@ -168,13 +169,11 @@ describe('pullApplication', () => { 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', @@ -193,11 +192,9 @@ 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, @@ -222,9 +219,468 @@ describe('pullApplication', () => { registry: '', url: '', }; - await manager.pullApplication(recipe, model); expect(cloneRepositoryMock).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', taskUtils)).toThrowError( + `The file located at ${path.join('local', 'config')} does not exist.`, + ); + expect(setTaskMock).toHaveBeenLastCalledWith({ + id: 'loading-config', + name: 'Loading configuration', + state: 'error', + }); + }); + + 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', taskUtils); + expect(setTaskMock).toHaveBeenLastCalledWith({ + id: 'loading-config', + name: 'Loading configuration', + state: 'success', + }); + 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 dd949b1a7..fd0062955 100644 --- a/packages/backend/src/managers/applicationManager.ts +++ b/packages/backend/src/managers/applicationManager.ts @@ -40,12 +40,12 @@ interface DownloadModelResult { error?: string; } -interface Pod { +export interface PodInfo { engineId: string; Id: string; } -interface ImageInfo { +export interface ImageInfo { id: string; modelService: boolean; ports: string[]; @@ -87,13 +87,13 @@ export class ApplicationManager { async createApplicationPod(images: ImageInfo[], modelPath: string, taskUtil: RecipeStatusUtils) { // create empty pod - let pod: Pod; + let pod: PodInfo; try { pod = await this.createPod(images); } catch (e) { console.error('error when creating pod'); taskUtil.setTask({ - id: pod.Id, + id: 'fake-pod-id', state: 'error', name: 'Creating application', }); @@ -115,7 +115,7 @@ export class ApplicationManager { }); } - async createAndAddContainersToPod(pod: Pod, images: ImageInfo[], modelPath: string) { + async createAndAddContainersToPod(pod: PodInfo, images: ImageInfo[], modelPath: string) { await Promise.all( images.map(async image => { let hostConfig: unknown; @@ -173,7 +173,7 @@ export class ApplicationManager { ); } - async createPod(images: ImageInfo[]): Promise { + 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) { @@ -254,6 +254,7 @@ 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)}`); }); }), ); @@ -269,7 +270,7 @@ export class ApplicationManager { if (!image) { console.error('no image found'); taskUtil.setTaskState(container.name, 'error'); - return; + throw new Error(`no image found for ${container.name}:latest`); } const imageInspectInfo = await containerEngine.getImageInspect(image.engineId, image.Id);