diff --git a/packages/backend/src/managers/applicationManager.spec.ts b/packages/backend/src/managers/applicationManager.spec.ts index bac207936..a97d8a148 100644 --- a/packages/backend/src/managers/applicationManager.spec.ts +++ b/packages/backend/src/managers/applicationManager.spec.ts @@ -16,7 +16,7 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ import { type MockInstance, describe, expect, test, vi, beforeEach } from 'vitest'; -import type { ContainerAttachedInfo, ImageInfo, PodInfo } from './applicationManager'; +import type { ContainerAttachedInfo, ImageInfo, ApplicationPodInfo } from './applicationManager'; import { LABEL_RECIPE_ID, ApplicationManager } from './applicationManager'; import type { RecipeStatusRegistry } from '../registries/RecipeStatusRegistry'; import type { GitManager } from './gitManager'; @@ -31,8 +31,16 @@ import type { AIConfig, ContainerConfig } from '../models/AIConfig'; import * as portsUtils from '../utils/ports'; import { goarch } from '../utils/arch'; import * as utils from '../utils/utils'; -import type { Webview, TelemetryLogger } from '@podman-desktop/api'; +import type { Webview, TelemetryLogger, PodInfo } from '@podman-desktop/api'; import type { CatalogManager } from './catalogManager'; +import type { + PodmanConnection, + machineStopHandle, + podRemoveHandle, + podStartHandle, + podStopHandle, + startupHandle, +} from './podmanConnection'; const mocks = vi.hoisted(() => { return { @@ -49,12 +57,31 @@ const mocks = vi.hoisted(() => { inspectContainerMock: vi.fn(), logUsageMock: vi.fn(), logErrorMock: vi.fn(), + + postMessageMock: vi.fn(), + getContainerConnectionsMock: vi.fn(), + pullImageMock: vi.fn(), + stopContainerMock: vi.fn(), + getFreePortMock: vi.fn(), + containerRegistrySubscribeMock: vi.fn(), + onPodStartMock: vi.fn(), + onPodStopMock: vi.fn(), + onPodRemoveMock: vi.fn(), + startupSubscribeMock: vi.fn(), + onMachineStopMock: vi.fn(), + listContainersMock: vi.fn(), + listPodsMock: vi.fn(), + stopPodMock: vi.fn(), + removePodMock: vi.fn(), }; }); vi.mock('../models/AIConfig', () => ({ parseYamlFile: mocks.parseYamlFileMock, })); vi.mock('@podman-desktop/api', () => ({ + provider: { + getContainerConnections: mocks.getContainerConnectionsMock, + }, containerEngine: { buildImage: mocks.buildImageMock, listImages: mocks.listImagesMock, @@ -66,8 +93,15 @@ vi.mock('@podman-desktop/api', () => ({ startPod: mocks.startPod, deleteContainer: mocks.deleteContainerMock, inspectContainer: mocks.inspectContainerMock, + pullImage: mocks.pullImageMock, + stopContainer: mocks.stopContainerMock, + listContainers: mocks.listContainersMock, + listPods: mocks.listPodsMock, + stopPod: mocks.stopPodMock, + removePod: mocks.removePodMock, }, })); + let setTaskMock: MockInstance; let taskUtils: RecipeStatusUtils; let setTaskStateMock: MockInstance; @@ -185,6 +219,9 @@ describe('pullApplication', () => { { setStatus: setStatusMock, } as unknown as RecipeStatusRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, modelsManager, telemetryLogger, ); @@ -362,6 +399,9 @@ describe('doCheckout', () => { cloneRepository: cloneRepositoryMock, } as unknown as GitManager, {} as unknown as RecipeStatusRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, {} as unknown as ModelsManager, telemetryLogger, ); @@ -396,6 +436,9 @@ describe('doCheckout', () => { cloneRepository: cloneRepositoryMock, } as unknown as GitManager, {} as unknown as RecipeStatusRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, {} as unknown as ModelsManager, telemetryLogger, ); @@ -426,6 +469,9 @@ describe('getConfiguration', () => { '/home/user/aistudio', {} as unknown as GitManager, {} as unknown as RecipeStatusRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, {} as unknown as ModelsManager, telemetryLogger, ); @@ -440,6 +486,9 @@ describe('getConfiguration', () => { '/home/user/aistudio', {} as unknown as GitManager, {} as unknown as RecipeStatusRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, {} as unknown as ModelsManager, telemetryLogger, ); @@ -491,6 +540,9 @@ describe('filterContainers', () => { '/home/user/aistudio', {} as unknown as GitManager, {} as unknown as RecipeStatusRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, {} as unknown as ModelsManager, telemetryLogger, ); @@ -527,6 +579,9 @@ describe('filterContainers', () => { '/home/user/aistudio', {} as unknown as GitManager, {} as unknown as RecipeStatusRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, {} as unknown as ModelsManager, telemetryLogger, ); @@ -573,6 +628,9 @@ describe('filterContainers', () => { '/home/user/aistudio', {} as unknown as GitManager, {} as unknown as RecipeStatusRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, {} as unknown as ModelsManager, telemetryLogger, ); @@ -589,6 +647,9 @@ describe('getRandomName', () => { '/home/user/aistudio', {} as unknown as GitManager, {} as unknown as RecipeStatusRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, {} as unknown as ModelsManager, telemetryLogger, ); @@ -601,6 +662,9 @@ describe('getRandomName', () => { '/home/user/aistudio', {} as unknown as GitManager, {} as unknown as RecipeStatusRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, {} as unknown as ModelsManager, telemetryLogger, ); @@ -624,6 +688,9 @@ describe('buildImages', () => { '/home/user/aistudio', {} as unknown as GitManager, {} as unknown as RecipeStatusRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, {} as unknown as ModelsManager, telemetryLogger, ); @@ -694,6 +761,9 @@ describe('createPod', async () => { '/home/user/aistudio', {} as unknown as GitManager, {} as unknown as RecipeStatusRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, {} as unknown as ModelsManager, telemetryLogger, ); @@ -755,6 +825,9 @@ describe('createApplicationPod', () => { '/home/user/aistudio', {} as unknown as GitManager, {} as unknown as RecipeStatusRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, {} as unknown as ModelsManager, telemetryLogger, ); @@ -778,7 +851,7 @@ describe('createApplicationPod', () => { }); }); test('call createAndAddContainersToPod after pod is created', async () => { - const pod: PodInfo = { + const pod: ApplicationPodInfo = { engineId: 'engine', Id: 'id', portmappings: [], @@ -786,7 +859,7 @@ describe('createApplicationPod', () => { vi.spyOn(manager, 'createPod').mockResolvedValue(pod); const createAndAddContainersToPodMock = vi .spyOn(manager, 'createAndAddContainersToPod') - .mockImplementation((_pod: PodInfo, _images: ImageInfo[], _modelPath: string) => Promise.resolve([])); + .mockImplementation((_pod: ApplicationPodInfo, _images: ImageInfo[], _modelPath: string) => Promise.resolve([])); await manager.createApplicationPod( { id: 'recipe-id' } as Recipe, { id: 'model-id' } as ModelInfo, @@ -802,7 +875,7 @@ describe('createApplicationPod', () => { }); }); test('throw if createAndAddContainersToPod fails', async () => { - const pod: PodInfo = { + const pod: ApplicationPodInfo = { engineId: 'engine', Id: 'id', portmappings: [], @@ -837,6 +910,9 @@ describe('restartContainerWhenModelServiceIsUp', () => { '/home/user/aistudio', {} as unknown as GitManager, {} as unknown as RecipeStatusRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, {} as unknown as ModelsManager, telemetryLogger, ); @@ -857,10 +933,13 @@ describe('runApplication', () => { '/home/user/aistudio', {} as unknown as GitManager, {} as unknown as RecipeStatusRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, {} as unknown as ModelsManager, telemetryLogger, ); - const pod: PodInfo = { + const pod: ApplicationPodInfo = { engineId: 'engine', Id: 'id', containers: [ @@ -907,10 +986,13 @@ describe('createAndAddContainersToPod', () => { '/home/user/aistudio', {} as unknown as GitManager, {} as unknown as RecipeStatusRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, {} as unknown as ModelsManager, telemetryLogger, ); - const pod: PodInfo = { + const pod: ApplicationPodInfo = { engineId: 'engine', Id: 'id', portmappings: [], @@ -952,3 +1034,230 @@ describe('createAndAddContainersToPod', () => { expect(mocks.deleteContainerMock).toBeCalledWith('engine', 'container-1'); }); }); + +describe('pod detection', async () => { + let manager: ApplicationManager; + + beforeEach(() => { + vi.resetAllMocks(); + + manager = new ApplicationManager( + '/path/to/user/dir', + {} as GitManager, + { + setStatus: vi.fn(), + } as unknown as RecipeStatusRegistry, + { + postMessage: mocks.postMessageMock, + } as unknown as Webview, + { + onPodStart: mocks.onPodStartMock, + onPodStop: mocks.onPodStopMock, + onPodRemove: mocks.onPodRemoveMock, + startupSubscribe: mocks.startupSubscribeMock, + onMachineStop: mocks.onMachineStopMock, + } as unknown as PodmanConnection, + {} as CatalogManager, + {} as ModelsManager, + {} as TelemetryLogger, + ); + }); + + test('adoptRunningEnvironments updates the environment state with the found pod', async () => { + mocks.listPodsMock.mockResolvedValue([ + { + Labels: { + 'ai-studio-recipe-id': 'recipe-id-1', + }, + }, + ]); + mocks.startupSubscribeMock.mockImplementation((f: startupHandle) => { + f(); + }); + const updateEnvironmentStateSpy = vi.spyOn(manager, 'updateEnvironmentState'); + manager.adoptRunningEnvironments(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(updateEnvironmentStateSpy).toHaveBeenNthCalledWith(1, 'recipe-id-1', { + pod: { + Labels: { + 'ai-studio-recipe-id': 'recipe-id-1', + }, + }, + recipeId: 'recipe-id-1', + }); + }); + + test('adoptRunningEnvironments does not update the environment state with the found pod without label', async () => { + mocks.listPodsMock.mockResolvedValue([{}]); + mocks.startupSubscribeMock.mockImplementation((f: startupHandle) => { + f(); + }); + const updateEnvironmentStateSpy = vi.spyOn(manager, 'updateEnvironmentState'); + manager.adoptRunningEnvironments(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(updateEnvironmentStateSpy).not.toHaveBeenCalled(); + }); + + test('onMachineStop updates the environments state with no environment running', async () => { + mocks.listPodsMock.mockResolvedValue([]); + mocks.onMachineStopMock.mockImplementation((f: machineStopHandle) => { + f(); + }); + const sendEnvironmentStateSpy = vi.spyOn(manager, 'sendEnvironmentState').mockResolvedValue(); + manager.adoptRunningEnvironments(); + expect(sendEnvironmentStateSpy).toHaveBeenCalledOnce(); + }); + + test('onPodStart updates the environments state with the started pod', async () => { + mocks.listPodsMock.mockResolvedValue([]); + mocks.onMachineStopMock.mockImplementation((_f: machineStopHandle) => {}); + mocks.onPodStartMock.mockImplementation((f: podStartHandle) => { + f({ + engineId: 'engine-1', + engineName: 'Engine 1', + kind: 'podman', + Labels: { + 'ai-studio-recipe-id': 'recipe-id-1', + }, + } as unknown as PodInfo); + }); + const sendEnvironmentStateSpy = vi.spyOn(manager, 'sendEnvironmentState').mockResolvedValue(); + manager.adoptRunningEnvironments(); + expect(sendEnvironmentStateSpy).toHaveBeenCalledOnce(); + }); + + test('onPodStart does no update the environments state with the started pod without labels', async () => { + mocks.listPodsMock.mockResolvedValue([]); + mocks.onMachineStopMock.mockImplementation((_f: machineStopHandle) => {}); + mocks.onPodStartMock.mockImplementation((f: podStartHandle) => { + f({ + engineId: 'engine-1', + engineName: 'Engine 1', + kind: 'podman', + } as unknown as PodInfo); + }); + const sendEnvironmentStateSpy = vi.spyOn(manager, 'sendEnvironmentState').mockResolvedValue(); + manager.adoptRunningEnvironments(); + expect(sendEnvironmentStateSpy).not.toHaveBeenCalledOnce(); + }); + + test('onPodStop updates the environments state by removing the stopped pod', async () => { + mocks.startupSubscribeMock.mockImplementation((f: startupHandle) => { + f(); + }); + mocks.listPodsMock.mockResolvedValue([ + { + Labels: { + 'ai-studio-recipe-id': 'recipe-id-1', + }, + }, + ]); + mocks.onMachineStopMock.mockImplementation((_f: machineStopHandle) => {}); + mocks.onPodStopMock.mockImplementation((f: podStopHandle) => { + setTimeout(() => { + f({ + engineId: 'engine-1', + engineName: 'Engine 1', + kind: 'podman', + Labels: { + 'ai-studio-recipe-id': 'recipe-id-1', + }, + } as unknown as PodInfo); + }, 1); + }); + const sendEnvironmentStateSpy = vi.spyOn(manager, 'sendEnvironmentState').mockResolvedValue(); + manager.adoptRunningEnvironments(); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(sendEnvironmentStateSpy).toHaveBeenCalledTimes(2); + }); + + test('onPodRemove updates the environments state by removing the removed pod', async () => { + mocks.startupSubscribeMock.mockImplementation((f: startupHandle) => { + f(); + }); + mocks.listPodsMock.mockResolvedValue([ + { + Id: 'pod-id-1', + Labels: { + 'ai-studio-recipe-id': 'recipe-id-1', + }, + }, + ]); + mocks.onMachineStopMock.mockImplementation((_f: machineStopHandle) => {}); + mocks.onPodRemoveMock.mockImplementation((f: podRemoveHandle) => { + setTimeout(() => { + f('pod-id-1'); + }, 1); + }); + const sendEnvironmentStateSpy = vi.spyOn(manager, 'sendEnvironmentState').mockResolvedValue(); + manager.adoptRunningEnvironments(); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(sendEnvironmentStateSpy).toHaveBeenCalledTimes(2); + }); + + test('getEnvironmentPod', async () => { + mocks.listPodsMock.mockResolvedValue([ + { + Labels: { + 'ai-studio-recipe-id': 'recipe-id-1', + }, + }, + { + Labels: { + 'ai-studio-recipe-id': 'recipe-id-2', + }, + }, + ]); + const result = await manager.getEnvironmentPod('recipe-id-1'); + expect(result).toEqual({ + Labels: { + 'ai-studio-recipe-id': 'recipe-id-1', + }, + }); + }); + + test('deleteEnvironment calls stopPod and removePod', async () => { + mocks.listPodsMock.mockResolvedValue([ + { + engineId: 'engine-1', + Id: 'pod-1', + Labels: { + 'ai-studio-recipe-id': 'recipe-id-1', + }, + }, + { + engineId: 'engine-2', + Id: 'pod-2', + Labels: { + 'ai-studio-recipe-id': 'recipe-id-2', + }, + }, + ]); + await manager.deleteEnvironment('recipe-id-1'); + expect(mocks.stopPodMock).toHaveBeenCalledWith('engine-1', 'pod-1'); + expect(mocks.removePodMock).toHaveBeenCalledWith('engine-1', 'pod-1'); + }); + + test('deleteEnvironment calls stopPod and removePod even if stopPod fails because pod already stopped', async () => { + mocks.listPodsMock.mockResolvedValue([ + { + engineId: 'engine-1', + Id: 'pod-1', + Labels: { + 'ai-studio-recipe-id': 'recipe-id-1', + }, + }, + { + engineId: 'engine-2', + Id: 'pod-2', + Labels: { + 'ai-studio-recipe-id': 'recipe-id-2', + }, + }, + ]); + mocks.stopPodMock.mockRejectedValue('something went wrong, pod already stopped...'); + await manager.deleteEnvironment('recipe-id-1'); + expect(mocks.stopPodMock).toHaveBeenCalledWith('engine-1', 'pod-1'); + expect(mocks.removePodMock).toHaveBeenCalledWith('engine-1', 'pod-1'); + }); +}); diff --git a/packages/backend/src/managers/applicationManager.ts b/packages/backend/src/managers/applicationManager.ts index f94c1e7c0..303669d3f 100644 --- a/packages/backend/src/managers/applicationManager.ts +++ b/packages/backend/src/managers/applicationManager.ts @@ -20,7 +20,13 @@ import type { Recipe } from '@shared/src/models/IRecipe'; import type { GitCloneInfo, GitManager } from './gitManager'; import fs from 'fs'; import * as path from 'node:path'; -import { type PodCreatePortOptions, containerEngine, type TelemetryLogger } from '@podman-desktop/api'; +import { + type PodCreatePortOptions, + containerEngine, + type TelemetryLogger, + type PodInfo, + type Webview, +} from '@podman-desktop/api'; import type { RecipeStatusRegistry } from '../registries/RecipeStatusRegistry'; import type { AIConfig, AIConfigFile, ContainerConfig } from '../models/AIConfig'; import { parseYamlFile } from '../models/AIConfig'; @@ -33,6 +39,10 @@ import { getPortsInfo } from '../utils/ports'; import { goarch } from '../utils/arch'; import { getDurationSecondsSince, isEndpointAlive, timeout } from '../utils/utils'; import { LABEL_MODEL_ID } from './playground'; +import type { EnvironmentState } from '@shared/src/models/IEnvironmentState'; +import type { PodmanConnection } from './podmanConnection'; +import { MSG_ENVIRONMENTS_STATE_UPDATE } from '@shared/Messages'; +import type { CatalogManager } from './catalogManager'; export const LABEL_RECIPE_ID = 'ai-studio-recipe-id'; @@ -49,7 +59,7 @@ export interface ContainerAttachedInfo { ports: string[]; } -export interface PodInfo { +export interface ApplicationPodInfo { engineId: string; Id: string; containers?: ContainerAttachedInfo[]; @@ -64,19 +74,29 @@ export interface ImageInfo { } export class ApplicationManager { + // Map recipeId => EnvironmentState + #environments: Map; + constructor( private appUserDirectory: string, private git: GitManager, private recipeStatusRegistry: RecipeStatusRegistry, + private webview: Webview, + private podmanConnection: PodmanConnection, + private catalogManager: CatalogManager, private modelsManager: ModelsManager, private telemetry: TelemetryLogger, - ) {} + ) { + this.#environments = new Map(); + } - async pullApplication(recipe: Recipe, model: ModelInfo) { + async pullApplication(recipe: Recipe, model: ModelInfo, taskUtil?: RecipeStatusUtils) { const startTime = performance.now(); try { // Create a TaskUtils object to help us - const taskUtil = new RecipeStatusUtils(recipe.id, this.recipeStatusRegistry); + if (!taskUtil) { + taskUtil = new RecipeStatusUtils(recipe.id, this.recipeStatusRegistry); + } const localFolder = path.join(this.appUserDirectory, recipe.id); @@ -106,7 +126,6 @@ export class ApplicationManager { const podInfo = await this.createApplicationPod(recipe, model, images, modelPath, taskUtil); await this.runApplication(podInfo, taskUtil); - taskUtil.setStatus('running'); const durationSeconds = getDurationSecondsSince(startTime); this.telemetry.logUsage('recipe.pull', { 'recipe.id': recipe.id, 'recipe.name': recipe.name, durationSeconds }); } catch (err: unknown) { @@ -122,7 +141,7 @@ export class ApplicationManager { } } - async runApplication(podInfo: PodInfo, taskUtil: RecipeStatusUtils) { + async runApplication(podInfo: ApplicationPodInfo, taskUtil: RecipeStatusUtils) { taskUtil.setTask({ id: `running-${podInfo.Id}`, state: 'loading', @@ -188,9 +207,9 @@ export class ApplicationManager { images: ImageInfo[], modelPath: string, taskUtil: RecipeStatusUtils, - ): Promise { + ): Promise { // create empty pod - let podInfo: PodInfo; + let podInfo: ApplicationPodInfo; try { podInfo = await this.createPod(recipe, model, images); } catch (e) { @@ -235,7 +254,7 @@ export class ApplicationManager { } async createAndAddContainersToPod( - podInfo: PodInfo, + podInfo: ApplicationPodInfo, images: ImageInfo[], modelPath: string, ): Promise { @@ -303,7 +322,7 @@ export class ApplicationManager { return containers; } - async createPod(recipe: Recipe, model: ModelInfo, images: ImageInfo[]): Promise { + async createPod(recipe: Recipe, model: ModelInfo, 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) { @@ -550,4 +569,186 @@ export class ApplicationManager { // Update task taskUtil.setTask(checkoutTask); } + + adoptRunningEnvironments() { + this.podmanConnection.startupSubscribe(() => { + if (!containerEngine.listPods) { + // TODO(feloy) this check can be safely removed when podman desktop 1.8 is released + // and the extension minimal version is set to 1.8 + return; + } + containerEngine + .listPods() + .then(pods => { + const envsPods = pods.filter(pod => LABEL_RECIPE_ID in pod.Labels); + for (const podToAdopt of envsPods) { + this.adoptPod(podToAdopt); + } + }) + .catch((err: unknown) => { + console.error('error during adoption of existing playground containers', err); + }); + }); + + this.podmanConnection.onMachineStop(() => { + // Podman Machine has been stopped, we consider all recipe pods are stopped + this.#environments.clear(); + this.sendEnvironmentState(); + }); + + this.podmanConnection.onPodStart((pod: PodInfo) => { + this.adoptPod(pod); + }); + this.podmanConnection.onPodStop((pod: PodInfo) => { + this.forgetPod(pod); + }); + this.podmanConnection.onPodRemove((podId: string) => { + this.forgetPodById(podId); + }); + } + + adoptPod(pod: PodInfo) { + if (!pod.Labels) { + return; + } + const recipeId = pod.Labels[LABEL_RECIPE_ID]; + const modelId = pod.Labels[LABEL_MODEL_ID]; + if (this.#environments.has(recipeId)) { + return; + } + const state: EnvironmentState = { + recipeId, + modelId, + pod, + }; + this.updateEnvironmentState(recipeId, state); + } + + forgetPod(pod: PodInfo) { + if (!pod.Labels) { + return; + } + const recipeId = pod.Labels[LABEL_RECIPE_ID]; + if (!this.#environments.has(recipeId)) { + return; + } + this.#environments.delete(recipeId); + this.sendEnvironmentState(); + } + + forgetPodById(podId: string) { + const env = Array.from(this.#environments.values()).find(p => p.pod.Id === podId); + if (!env) { + return; + } + if (!env.pod.Labels) { + return; + } + const recipeId = env.pod.Labels[LABEL_RECIPE_ID]; + if (!this.#environments.has(recipeId)) { + return; + } + this.#environments.delete(recipeId); + this.sendEnvironmentState(); + } + + updateEnvironmentState(recipeId: string, state: EnvironmentState): void { + this.#environments.set(recipeId, state); + this.sendEnvironmentState(); + } + + getEnvironmentsState(): EnvironmentState[] { + return Array.from(this.#environments.values()); + } + + sendEnvironmentState() { + this.webview + .postMessage({ + id: MSG_ENVIRONMENTS_STATE_UPDATE, + body: this.getEnvironmentsState(), + }) + .catch((err: unknown) => { + console.error(`Something went wrong while emitting MSG_ENVIRONMENTS_STATE_UPDATE: ${String(err)}`); + }); + } + + async deleteEnvironment(recipeId: string, taskUtil?: RecipeStatusUtils) { + if (!taskUtil) { + taskUtil = new RecipeStatusUtils(recipeId, this.recipeStatusRegistry); + } + try { + taskUtil.setTask({ + id: `stopping-${recipeId}`, + state: 'loading', + name: `Stopping application`, + }); + const envPod = await this.getEnvironmentPod(recipeId); + try { + await containerEngine.stopPod(envPod.engineId, envPod.Id); + taskUtil.setTask({ + id: `stopping-${recipeId}`, + state: 'success', + name: `Application stopped`, + }); + } catch (err: unknown) { + // continue when the pod is already stopped + if (!String(err).includes('pod already stopped')) { + taskUtil.setTask({ + id: `stopping-${recipeId}`, + state: 'error', + error: 'error stopping the pod. Please try to remove the pod manually', + name: `Error stopping application`, + }); + throw err; + } + taskUtil.setTask({ + id: `stopping-${recipeId}`, + state: 'success', + name: `Application stopped`, + }); + } + taskUtil.setTask({ + id: `removing-${recipeId}`, + state: 'loading', + name: `Removing application`, + }); + await containerEngine.removePod(envPod.engineId, envPod.Id); + taskUtil.setTask({ + id: `removing-${recipeId}`, + state: 'success', + name: `Application removed`, + }); + } catch (err: unknown) { + taskUtil.setTask({ + id: `removing-${recipeId}`, + state: 'error', + error: 'error removing the pod. Please try to remove the pod manually', + name: `Error removing application`, + }); + throw err; + } + } + + async restartEnvironment(recipeId: string) { + const taskUtil = new RecipeStatusUtils(recipeId, this.recipeStatusRegistry); + const envPod = await this.getEnvironmentPod(recipeId); + await this.deleteEnvironment(recipeId, taskUtil); + const recipe = this.catalogManager.getRecipeById(recipeId); + const model = this.catalogManager.getModelById(envPod.Labels[LABEL_MODEL_ID]); + await this.pullApplication(recipe, model, taskUtil); + } + + async getEnvironmentPod(recipeId: string): Promise { + if (!containerEngine.listPods || !containerEngine.stopPod || !containerEngine.removePod) { + // TODO(feloy) this check can be safely removed when podman desktop 1.8 is released + // and the extension minimal version is set to 1.8 + return; + } + const pods = await containerEngine.listPods(); + const envPod = pods.find(pod => LABEL_RECIPE_ID in pod.Labels && pod.Labels[LABEL_RECIPE_ID] === recipeId); + if (!envPod) { + throw new Error(`no pod found with recipe Id ${recipeId}`); + } + return envPod; + } } diff --git a/packages/backend/src/managers/environmentManager.spec.ts b/packages/backend/src/managers/environmentManager.spec.ts deleted file mode 100644 index 143b6a067..000000000 --- a/packages/backend/src/managers/environmentManager.spec.ts +++ /dev/null @@ -1,297 +0,0 @@ -/********************************************************************** - * 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 { beforeEach, expect, test, vi } from 'vitest'; -import { EnvironmentManager } from './environmentManager'; -import type { PodInfo, Webview } from '@podman-desktop/api'; -import type { - PodmanConnection, - machineStopHandle, - podRemoveHandle, - podStartHandle, - podStopHandle, - startupHandle, -} from './podmanConnection'; -import type { ApplicationManager } from './applicationManager'; -import type { CatalogManager } from './catalogManager'; - -let manager: EnvironmentManager; - -const mocks = vi.hoisted(() => ({ - postMessage: vi.fn(), - getContainerConnections: vi.fn(), - pullImage: vi.fn(), - createContainer: vi.fn(), - stopContainer: vi.fn(), - getFreePort: vi.fn(), - containerRegistrySubscribeMock: vi.fn(), - onPodStart: vi.fn(), - onPodStop: vi.fn(), - onPodRemove: vi.fn(), - startupSubscribe: vi.fn(), - onMachineStop: vi.fn(), - listContainers: vi.fn(), - listPods: vi.fn(), - stopPod: vi.fn(), - removePod: vi.fn(), - logUsage: vi.fn(), - logError: vi.fn(), -})); - -vi.mock('@podman-desktop/api', async () => { - return { - provider: { - getContainerConnections: mocks.getContainerConnections, - }, - containerEngine: { - pullImage: mocks.pullImage, - createContainer: mocks.createContainer, - stopContainer: mocks.stopContainer, - listContainers: mocks.listContainers, - listPods: mocks.listPods, - stopPod: mocks.stopPod, - removePod: mocks.removePod, - }, - }; -}); - -beforeEach(() => { - vi.resetAllMocks(); - - manager = new EnvironmentManager( - { - postMessage: mocks.postMessage, - } as unknown as Webview, - { - onPodStart: mocks.onPodStart, - onPodStop: mocks.onPodStop, - onPodRemove: mocks.onPodRemove, - startupSubscribe: mocks.startupSubscribe, - onMachineStop: mocks.onMachineStop, - } as unknown as PodmanConnection, - {} as ApplicationManager, - {} as CatalogManager, - ); -}); - -test('adoptRunningEnvironments updates the environment state with the found pod', async () => { - mocks.listPods.mockResolvedValue([ - { - Labels: { - 'ai-studio-recipe-id': 'recipe-id-1', - }, - }, - ]); - mocks.startupSubscribe.mockImplementation((f: startupHandle) => { - f(); - }); - const updateEnvironmentStateSpy = vi.spyOn(manager, 'updateEnvironmentState'); - manager.adoptRunningEnvironments(); - await new Promise(resolve => setTimeout(resolve, 0)); - expect(updateEnvironmentStateSpy).toHaveBeenNthCalledWith(1, 'recipe-id-1', { - pod: { - Labels: { - 'ai-studio-recipe-id': 'recipe-id-1', - }, - }, - recipeId: 'recipe-id-1', - status: 'running', - }); -}); - -test('adoptRunningEnvironments does not update the environment state with the found pod without label', async () => { - mocks.listPods.mockResolvedValue([{}]); - mocks.startupSubscribe.mockImplementation((f: startupHandle) => { - f(); - }); - const updateEnvironmentStateSpy = vi.spyOn(manager, 'updateEnvironmentState'); - manager.adoptRunningEnvironments(); - await new Promise(resolve => setTimeout(resolve, 0)); - expect(updateEnvironmentStateSpy).not.toHaveBeenCalled(); -}); - -test('onMachineStop updates the environments state with no environment running', async () => { - mocks.listPods.mockResolvedValue([]); - mocks.onMachineStop.mockImplementation((f: machineStopHandle) => { - f(); - }); - const sendEnvironmentStateSpy = vi.spyOn(manager, 'sendEnvironmentState').mockResolvedValue(); - manager.adoptRunningEnvironments(); - expect(sendEnvironmentStateSpy).toHaveBeenCalledOnce(); -}); - -test('onPodStart updates the environments state with the started pod', async () => { - mocks.listPods.mockResolvedValue([]); - mocks.onMachineStop.mockImplementation((_f: machineStopHandle) => {}); - mocks.onPodStart.mockImplementation((f: podStartHandle) => { - f({ - engineId: 'engine-1', - engineName: 'Engine 1', - kind: 'podman', - Labels: { - 'ai-studio-recipe-id': 'recipe-id-1', - }, - } as unknown as PodInfo); - }); - const sendEnvironmentStateSpy = vi.spyOn(manager, 'sendEnvironmentState').mockResolvedValue(); - manager.adoptRunningEnvironments(); - expect(sendEnvironmentStateSpy).toHaveBeenCalledOnce(); -}); - -test('onPodStart does no update the environments state with the started pod without labels', async () => { - mocks.listPods.mockResolvedValue([]); - mocks.onMachineStop.mockImplementation((_f: machineStopHandle) => {}); - mocks.onPodStart.mockImplementation((f: podStartHandle) => { - f({ - engineId: 'engine-1', - engineName: 'Engine 1', - kind: 'podman', - } as unknown as PodInfo); - }); - const sendEnvironmentStateSpy = vi.spyOn(manager, 'sendEnvironmentState').mockResolvedValue(); - manager.adoptRunningEnvironments(); - expect(sendEnvironmentStateSpy).not.toHaveBeenCalledOnce(); -}); - -test('onPodStop updates the environments state by removing the stopped pod', async () => { - mocks.startupSubscribe.mockImplementation((f: startupHandle) => { - f(); - }); - mocks.listPods.mockResolvedValue([ - { - Labels: { - 'ai-studio-recipe-id': 'recipe-id-1', - }, - }, - ]); - mocks.onMachineStop.mockImplementation((_f: machineStopHandle) => {}); - mocks.onPodStop.mockImplementation((f: podStopHandle) => { - setTimeout(() => { - f({ - engineId: 'engine-1', - engineName: 'Engine 1', - kind: 'podman', - Labels: { - 'ai-studio-recipe-id': 'recipe-id-1', - }, - } as unknown as PodInfo); - }, 1); - }); - const sendEnvironmentStateSpy = vi.spyOn(manager, 'sendEnvironmentState').mockResolvedValue(); - manager.adoptRunningEnvironments(); - await new Promise(resolve => setTimeout(resolve, 10)); - expect(sendEnvironmentStateSpy).toHaveBeenCalledTimes(2); -}); - -test('onPodRemove updates the environments state by removing the removed pod', async () => { - mocks.startupSubscribe.mockImplementation((f: startupHandle) => { - f(); - }); - mocks.listPods.mockResolvedValue([ - { - Id: 'pod-id-1', - Labels: { - 'ai-studio-recipe-id': 'recipe-id-1', - }, - }, - ]); - mocks.onMachineStop.mockImplementation((_f: machineStopHandle) => {}); - mocks.onPodRemove.mockImplementation((f: podRemoveHandle) => { - setTimeout(() => { - f('pod-id-1'); - }, 1); - }); - const sendEnvironmentStateSpy = vi.spyOn(manager, 'sendEnvironmentState').mockResolvedValue(); - manager.adoptRunningEnvironments(); - await new Promise(resolve => setTimeout(resolve, 10)); - expect(sendEnvironmentStateSpy).toHaveBeenCalledTimes(2); -}); - -test('getEnvironmentPod', async () => { - mocks.listPods.mockResolvedValue([ - { - Labels: { - 'ai-studio-recipe-id': 'recipe-id-1', - }, - }, - { - Labels: { - 'ai-studio-recipe-id': 'recipe-id-2', - }, - }, - ]); - const result = await manager.getEnvironmentPod('recipe-id-1'); - expect(result).toEqual({ - Labels: { - 'ai-studio-recipe-id': 'recipe-id-1', - }, - }); -}); - -test('deleteEnvironment calls stopPod and removePod', async () => { - mocks.listPods.mockResolvedValue([ - { - engineId: 'engine-1', - Id: 'pod-1', - Labels: { - 'ai-studio-recipe-id': 'recipe-id-1', - }, - }, - { - engineId: 'engine-2', - Id: 'pod-2', - Labels: { - 'ai-studio-recipe-id': 'recipe-id-2', - }, - }, - ]); - const setEnvironmentStatusSpy = vi.spyOn(manager, 'setEnvironmentStatus'); - setEnvironmentStatusSpy.mockReturnValue(); - await manager.deleteEnvironment('recipe-id-1'); - expect(mocks.stopPod).toHaveBeenCalledWith('engine-1', 'pod-1'); - expect(mocks.removePod).toHaveBeenCalledWith('engine-1', 'pod-1'); - expect(setEnvironmentStatusSpy).toHaveBeenNthCalledWith(1, 'recipe-id-1', 'stopping'); - expect(setEnvironmentStatusSpy).toHaveBeenNthCalledWith(2, 'recipe-id-1', 'removing'); -}); - -test('deleteEnvironment calls stopPod and removePod even if stopPod fails because pod already stopped', async () => { - mocks.listPods.mockResolvedValue([ - { - engineId: 'engine-1', - Id: 'pod-1', - Labels: { - 'ai-studio-recipe-id': 'recipe-id-1', - }, - }, - { - engineId: 'engine-2', - Id: 'pod-2', - Labels: { - 'ai-studio-recipe-id': 'recipe-id-2', - }, - }, - ]); - const setEnvironmentStatusSpy = vi.spyOn(manager, 'setEnvironmentStatus'); - setEnvironmentStatusSpy.mockReturnValue(); - mocks.stopPod.mockRejectedValue('something went wrong, pod already stopped...'); - await manager.deleteEnvironment('recipe-id-1'); - expect(mocks.stopPod).toHaveBeenCalledWith('engine-1', 'pod-1'); - expect(mocks.removePod).toHaveBeenCalledWith('engine-1', 'pod-1'); - expect(setEnvironmentStatusSpy).toHaveBeenNthCalledWith(1, 'recipe-id-1', 'stopping'); - expect(setEnvironmentStatusSpy).toHaveBeenNthCalledWith(2, 'recipe-id-1', 'removing'); -}); diff --git a/packages/backend/src/managers/environmentManager.ts b/packages/backend/src/managers/environmentManager.ts deleted file mode 100644 index 7e260c301..000000000 --- a/packages/backend/src/managers/environmentManager.ts +++ /dev/null @@ -1,202 +0,0 @@ -/********************************************************************** - * 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 { type PodInfo, type Webview, containerEngine } from '@podman-desktop/api'; -import type { PodmanConnection } from './podmanConnection'; -import type { ApplicationManager } from './applicationManager'; -import { LABEL_RECIPE_ID } from './applicationManager'; -import { MSG_ENVIRONMENTS_STATE_UPDATE } from '@shared/Messages'; -import type { EnvironmentState, EnvironmentStatus } from '@shared/src/models/IEnvironmentState'; -import type { CatalogManager } from './catalogManager'; -import { LABEL_MODEL_ID } from './playground'; - -/** - * An Environment is represented as a Pod, independently on how it has been created (by applicationManager or any other manager) - * A requisite is that the Pod defines a label LABEL_RECIPE_ID - */ -export class EnvironmentManager { - #environments: Map; - - constructor( - private webview: Webview, - private podmanConnection: PodmanConnection, - private applicationManager: ApplicationManager, - private catalogManager: CatalogManager, - ) { - this.#environments = new Map(); - } - - adoptRunningEnvironments() { - this.podmanConnection.startupSubscribe(() => { - if (!containerEngine.listPods) { - // TODO(feloy) this check can be safely removed when podman desktop 1.8 is released - // and the extension minimal version is set to 1.8 - return; - } - containerEngine - .listPods() - .then(pods => { - const envsPods = pods.filter(pod => LABEL_RECIPE_ID in pod.Labels); - for (const podToAdopt of envsPods) { - this.adoptPod(podToAdopt, 'running'); - } - }) - .catch((err: unknown) => { - console.error('error during adoption of existing playground containers', err); - }); - }); - - this.podmanConnection.onMachineStop(() => { - // Podman Machine has been stopped, we consider all recipe pods are stopped - this.#environments.clear(); - this.sendEnvironmentState(); - }); - - this.podmanConnection.onPodStart((pod: PodInfo) => { - this.adoptPod(pod, 'running'); - }); - this.podmanConnection.onPodStop((pod: PodInfo) => { - this.forgetPod(pod); - }); - this.podmanConnection.onPodRemove((podId: string) => { - this.forgetPodById(podId); - }); - } - - adoptPod(pod: PodInfo, status: EnvironmentStatus) { - if (!pod.Labels) { - return; - } - const recipeId = pod.Labels[LABEL_RECIPE_ID]; - if (this.#environments.has(recipeId)) { - return; - } - const state: EnvironmentState = { - recipeId, - pod, - status, - }; - this.updateEnvironmentState(recipeId, state); - } - - forgetPod(pod: PodInfo) { - if (!pod.Labels) { - return; - } - const recipeId = pod.Labels[LABEL_RECIPE_ID]; - if (!this.#environments.has(recipeId)) { - return; - } - this.#environments.delete(recipeId); - this.sendEnvironmentState(); - } - - forgetPodById(podId: string) { - const env = Array.from(this.#environments.values()).find(p => p.pod.Id === podId); - if (!env) { - return; - } - if (!env.pod.Labels) { - return; - } - const recipeId = env.pod.Labels[LABEL_RECIPE_ID]; - if (!this.#environments.has(recipeId)) { - return; - } - this.#environments.delete(recipeId); - this.sendEnvironmentState(); - } - - updateEnvironmentState(recipeId: string, state: EnvironmentState): void { - this.#environments.set(recipeId, state); - this.sendEnvironmentState(); - } - - getEnvironmentsState(): EnvironmentState[] { - return Array.from(this.#environments.values()); - } - - sendEnvironmentState() { - this.webview - .postMessage({ - id: MSG_ENVIRONMENTS_STATE_UPDATE, - body: this.getEnvironmentsState(), - }) - .catch((err: unknown) => { - console.error(`Something went wrong while emitting MSG_ENVIRONMENTS_STATE_UPDATE: ${String(err)}`); - }); - } - - async deleteEnvironment(recipeId: string) { - try { - this.setEnvironmentStatus(recipeId, 'stopping'); - const envPod = await this.getEnvironmentPod(recipeId); - try { - await containerEngine.stopPod(envPod.engineId, envPod.Id); - } catch (err: unknown) { - // continue when the pod is already stopped - if (!String(err).includes('pod already stopped')) { - throw err; - } - } - this.setEnvironmentStatus(recipeId, 'removing'); - await containerEngine.removePod(envPod.engineId, envPod.Id); - } catch (err: unknown) { - this.setEnvironmentStatus(recipeId, 'unknown'); - throw err; - } - } - - async restartEnvironment(recipeId: string) { - const envPod = await this.getEnvironmentPod(recipeId); - await this.deleteEnvironment(recipeId); - try { - const recipe = this.catalogManager.getRecipeById(recipeId); - const model = this.catalogManager.getModelById(envPod.Labels[LABEL_MODEL_ID]); - await this.applicationManager.pullApplication(recipe, model); - } catch (err: unknown) { - this.setEnvironmentStatus(recipeId, 'unknown'); - throw err; - } - } - - async getEnvironmentPod(recipeId: string): Promise { - if (!containerEngine.listPods || !containerEngine.stopPod || !containerEngine.removePod) { - // TODO(feloy) this check can be safely removed when podman desktop 1.8 is released - // and the extension minimal version is set to 1.8 - return; - } - const pods = await containerEngine.listPods(); - const envPod = pods.find(pod => LABEL_RECIPE_ID in pod.Labels && pod.Labels[LABEL_RECIPE_ID] === recipeId); - if (!envPod) { - throw new Error(`no pod found with recipe Id ${recipeId}`); - } - return envPod; - } - - setEnvironmentStatus(recipeId: string, status: EnvironmentStatus): void { - if (!this.#environments.has(recipeId)) { - throw new Error(`status for environemnt ${recipeId} not found`); - } - const previous = this.#environments.get(recipeId); - this.updateEnvironmentState(recipeId, { - ...previous, - status: status, - }); - } -} diff --git a/packages/backend/src/registries/RecipeStatusRegistry.spec.ts b/packages/backend/src/registries/RecipeStatusRegistry.spec.ts index a1b12daba..9ad21eee1 100644 --- a/packages/backend/src/registries/RecipeStatusRegistry.spec.ts +++ b/packages/backend/src/registries/RecipeStatusRegistry.spec.ts @@ -47,7 +47,6 @@ test('taskRegistry should have been updated', () => { const recipeStatusRegistry = new RecipeStatusRegistry(taskRegistry, webview); recipeStatusRegistry.setStatus('random', { recipeId: 'random', - state: 'none', tasks: [ { id: 'task-1', @@ -68,7 +67,6 @@ test('webview should have been notified', () => { const recipeStatusRegistry = new RecipeStatusRegistry(taskRegistry, webview); recipeStatusRegistry.setStatus('random', { recipeId: 'random', - state: 'none', tasks: [], }); expect(mocks.postMessageMock).toHaveBeenNthCalledWith(1, { @@ -78,7 +76,6 @@ test('webview should have been notified', () => { 'random', { recipeId: 'random', - state: 'none', tasks: [], }, ], @@ -90,7 +87,6 @@ test('recipe status should have been updated', () => { const recipeStatusRegistry = new RecipeStatusRegistry(taskRegistry, webview); recipeStatusRegistry.setStatus('random', { recipeId: 'random', - state: 'none', tasks: [ { id: 'task-1', @@ -99,15 +95,7 @@ test('recipe status should have been updated', () => { }, ], }); - let statuses = recipeStatusRegistry.getStatuses(); + const statuses = recipeStatusRegistry.getStatuses(); expect(statuses.size).toBe(1); expect(statuses.get('random').tasks.length).toBe(1); - expect(statuses.get('random').state).toBe('none'); - - // update the recipe state - recipeStatusRegistry.setRecipeState('random', 'error'); - statuses = recipeStatusRegistry.getStatuses(); - expect(statuses.size).toBe(1); - expect(statuses.get('random').tasks.length).toBe(1); - expect(statuses.get('random').state).toBe('error'); }); diff --git a/packages/backend/src/registries/RecipeStatusRegistry.ts b/packages/backend/src/registries/RecipeStatusRegistry.ts index a3d10a620..e614bec27 100644 --- a/packages/backend/src/registries/RecipeStatusRegistry.ts +++ b/packages/backend/src/registries/RecipeStatusRegistry.ts @@ -16,7 +16,7 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ -import type { RecipeStatus, RecipeStatusState } from '@shared/src/models/IRecipeStatus'; +import type { RecipeStatus } from '@shared/src/models/IRecipeStatus'; import type { TaskRegistry } from './TaskRegistry'; import type { Webview } from '@podman-desktop/api'; import { MSG_NEW_RECIPE_STATE } from '@shared/Messages'; @@ -40,15 +40,6 @@ export class RecipeStatusRegistry { }); // we don't want to wait } - setRecipeState(recipeId: string, state: RecipeStatusState): void { - if (!this.statuses.has(recipeId)) throw new Error(`The recipe status with id ${recipeId} does not exist.`); - const recipeStatus = this.statuses.get(recipeId); - this.statuses.set(recipeId, { - ...recipeStatus, - state: state, - }); - } - getStatus(recipeId: string): RecipeStatus | undefined { return this.statuses.get(recipeId); } diff --git a/packages/backend/src/studio-api-impl.spec.ts b/packages/backend/src/studio-api-impl.spec.ts index bbc3daf2a..85f894e42 100644 --- a/packages/backend/src/studio-api-impl.spec.ts +++ b/packages/backend/src/studio-api-impl.spec.ts @@ -28,7 +28,6 @@ import type { PlayGroundManager } from './managers/playground'; import type { TelemetryLogger, Webview } from '@podman-desktop/api'; import { CatalogManager } from './managers/catalogManager'; import type { ModelsManager } from './managers/modelsManager'; -import type { EnvironmentManager } from './managers/environmentManager'; import * as fs from 'node:fs'; import { timeout } from './utils/utils'; @@ -98,14 +97,13 @@ beforeEach(async () => { // Creating StudioApiImpl studioApiImpl = new StudioApiImpl( - {} as unknown as ApplicationManager, + { + deleteEnvironment: mocks.deleteEnvironmentMock, + } as unknown as ApplicationManager, {} as unknown as RecipeStatusRegistry, {} as unknown as PlayGroundManager, catalogManager, {} as unknown as ModelsManager, - { - deleteEnvironment: mocks.deleteEnvironmentMock, - } as unknown as EnvironmentManager, {} as TelemetryLogger, ); vi.resetAllMocks(); diff --git a/packages/backend/src/studio-api-impl.ts b/packages/backend/src/studio-api-impl.ts index 2b4c379d8..d05581d8f 100644 --- a/packages/backend/src/studio-api-impl.ts +++ b/packages/backend/src/studio-api-impl.ts @@ -30,7 +30,6 @@ import type { Catalog } from '@shared/src/models/ICatalog'; import type { PlaygroundState } from '@shared/src/models/IPlaygroundState'; import type { ModelsManager } from './managers/modelsManager'; import type { EnvironmentState } from '@shared/src/models/IEnvironmentState'; -import type { EnvironmentManager } from './managers/environmentManager'; export class StudioApiImpl implements StudioAPI { constructor( @@ -39,7 +38,6 @@ export class StudioApiImpl implements StudioAPI { private playgroundManager: PlayGroundManager, private catalogManager: CatalogManager, private modelsManager: ModelsManager, - private environmentManager: EnvironmentManager, private telemetry: podmanDesktopApi.TelemetryLogger, ) {} @@ -75,7 +73,11 @@ export class StudioApiImpl implements StudioAPI { this.applicationManager.pullApplication(recipe, model), ) .catch(() => { - this.recipeStatusRegistry.setRecipeState(recipeId, 'error'); + podmanDesktopApi.window + .showErrorMessage(`Error starting the application "${recipe.name}"`) + .catch((err: unknown) => { + console.error(`Something went wrong with confirmation modals`, err); + }); }); } @@ -148,7 +150,7 @@ export class StudioApiImpl implements StudioAPI { } async getEnvironmentsState(): Promise { - return this.environmentManager.getEnvironmentsState(); + return this.applicationManager.getEnvironmentsState(); } async requestRemoveEnvironment(recipeId: string): Promise { @@ -162,7 +164,7 @@ export class StudioApiImpl implements StudioAPI { ) .then((result: string) => { if (result === 'Confirm') { - this.environmentManager.deleteEnvironment(recipeId).catch((err: unknown) => { + this.applicationManager.deleteEnvironment(recipeId).catch((err: unknown) => { console.error(`error deleting environment pod: ${String(err)}`); podmanDesktopApi.window .showErrorMessage( @@ -190,7 +192,7 @@ export class StudioApiImpl implements StudioAPI { ) .then((result: string) => { if (result === 'Confirm') { - this.environmentManager.restartEnvironment(recipeId).catch((err: unknown) => { + this.applicationManager.restartEnvironment(recipeId).catch((err: unknown) => { console.error(`error restarting environment: ${String(err)}`); podmanDesktopApi.window .showErrorMessage(`Error restarting the environment "${recipe.name}"`) diff --git a/packages/backend/src/studio.ts b/packages/backend/src/studio.ts index 6852dc4e2..38d9f078e 100644 --- a/packages/backend/src/studio.ts +++ b/packages/backend/src/studio.ts @@ -38,7 +38,6 @@ import os from 'os'; import fs from 'node:fs'; import { ContainerRegistry } from './registries/ContainerRegistry'; import { PodmanConnection } from './managers/podmanConnection'; -import { EnvironmentManager } from './managers/environmentManager'; // TODO: Need to be configured export const AI_STUDIO_FOLDER = path.join('podman-desktop', 'ai-studio'); @@ -133,14 +132,11 @@ export class Studio { appUserDirectory, gitManager, recipeStatusRegistry, - this.modelsManager, - this.telemetry, - ); - const envManager = new EnvironmentManager( this.#panel.webview, podmanConnection, - applicationManager, this.catalogManager, + this.modelsManager, + this.telemetry, ); this.#panel.onDidChangeViewState((e: WebviewPanelOnDidChangeViewStateEvent) => { @@ -154,7 +150,6 @@ export class Studio { this.playgroundManager, this.catalogManager, this.modelsManager, - envManager, this.telemetry, ); @@ -162,7 +157,7 @@ export class Studio { await this.modelsManager.loadLocalModels(); podmanConnection.init(); this.playgroundManager.adoptRunningPlaygrounds(); - envManager.adoptRunningEnvironments(); + applicationManager.adoptRunningEnvironments(); // Register the instance this.rpcExtension.registerInstance(StudioApiImpl, this.studioApi); diff --git a/packages/backend/src/utils/recipeStatusUtils.ts b/packages/backend/src/utils/recipeStatusUtils.ts index d316e6117..65fae222c 100644 --- a/packages/backend/src/utils/recipeStatusUtils.ts +++ b/packages/backend/src/utils/recipeStatusUtils.ts @@ -16,13 +16,12 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ -import type { RecipeStatus, RecipeStatusState } from '@shared/src/models/IRecipeStatus'; +import type { RecipeStatus } from '@shared/src/models/IRecipeStatus'; import type { Task } from '@shared/src/models/ITask'; import type { RecipeStatusRegistry } from '../registries/RecipeStatusRegistry'; export class RecipeStatusUtils { private tasks: Map = new Map(); - private state: RecipeStatusState = 'loading'; constructor( private recipeId: string, @@ -33,16 +32,8 @@ export class RecipeStatusUtils { this.recipeStatusRegistry.setStatus(this.recipeId, this.toRecipeStatus()); } - setStatus(state: RecipeStatusState): void { - this.state = state; - this.update(); - } - setTask(task: Task) { this.tasks.set(task.id, task); - - if (task.state === 'error') this.setStatus('error'); - this.update(); } @@ -82,7 +73,6 @@ export class RecipeStatusUtils { toRecipeStatus(): RecipeStatus { return { recipeId: this.recipeId, - state: this.state, tasks: Array.from(this.tasks.values()), }; } diff --git a/packages/frontend/src/lib/EnvironmentActions.svelte b/packages/frontend/src/lib/EnvironmentActions.svelte new file mode 100644 index 000000000..3d602b45d --- /dev/null +++ b/packages/frontend/src/lib/EnvironmentActions.svelte @@ -0,0 +1,49 @@ + + + + + + + diff --git a/packages/frontend/src/lib/RecipeDetails.spec.ts b/packages/frontend/src/lib/RecipeDetails.spec.ts index 852d207e9..7ad31bb20 100644 --- a/packages/frontend/src/lib/RecipeDetails.spec.ts +++ b/packages/frontend/src/lib/RecipeDetails.spec.ts @@ -30,6 +30,7 @@ const mocks = vi.hoisted(() => { return { getPullingStatusesMock: vi.fn(), pullApplicationMock: vi.fn(), + getEnvironmentsStateMock: vi.fn(), }; }); @@ -38,6 +39,7 @@ vi.mock('../utils/client', async () => { studioClient: { getPullingStatuses: mocks.getPullingStatusesMock, pullApplication: mocks.pullApplicationMock, + getEnvironmentsState: mocks.getEnvironmentsStateMock, }, rpcBrowser: { subscribe: () => { @@ -105,6 +107,7 @@ beforeEach(() => { }); test('should open/close application details panel when clicking on toggle button', async () => { + mocks.getEnvironmentsStateMock.mockResolvedValue([]); vi.mocked(catalogStore).catalog = readable(initialCatalog); mocks.getPullingStatusesMock.mockResolvedValue(new Map()); render(RecipeDetails, { @@ -132,6 +135,7 @@ test('should open/close application details panel when clicking on toggle button }); test('should call runApplication execution when run application button is clicked', async () => { + mocks.getEnvironmentsStateMock.mockResolvedValue([]); vi.mocked(catalogStore).catalog = readable(initialCatalog); mocks.pullApplicationMock.mockResolvedValue(undefined); mocks.getPullingStatusesMock.mockResolvedValue(new Map()); @@ -140,13 +144,14 @@ test('should call runApplication execution when run application button is clicke modelId: 'model1', }); - const btnRunApplication = screen.getByRole('button', { name: 'Run application' }); + const btnRunApplication = screen.getByLabelText('Start Environment'); await userEvent.click(btnRunApplication); expect(mocks.pullApplicationMock).toBeCalledWith('recipe 1', 'model1'); }); test('swap model button should move user to models tab', async () => { + mocks.getEnvironmentsStateMock.mockResolvedValue([]); vi.mocked(catalogStore).catalog = readable(initialCatalog); mocks.getPullingStatusesMock.mockResolvedValue(new Map()); const gotoMock = vi.spyOn(router, 'goto'); @@ -162,6 +167,7 @@ test('swap model button should move user to models tab', async () => { }); test('swap model panel should be hidden on models tab', async () => { + mocks.getEnvironmentsStateMock.mockResolvedValue([]); vi.mocked(catalogStore).catalog = readable(initialCatalog); mocks.getPullingStatusesMock.mockResolvedValue(new Map()); render(RecipeDetails, { @@ -184,6 +190,7 @@ test('swap model panel should be hidden on models tab', async () => { }); test('should display default model information when model is the recommended', async () => { + mocks.getEnvironmentsStateMock.mockResolvedValue([]); vi.mocked(catalogStore).catalog = readable(initialCatalog); mocks.getPullingStatusesMock.mockResolvedValue(new Map()); render(RecipeDetails, { @@ -200,6 +207,7 @@ test('should display default model information when model is the recommended', a }); test('should display non-default model information when model is not the recommended one', async () => { + mocks.getEnvironmentsStateMock.mockResolvedValue([]); vi.mocked(catalogStore).catalog = readable(initialCatalog); mocks.getPullingStatusesMock.mockResolvedValue(new Map()); render(RecipeDetails, { diff --git a/packages/frontend/src/lib/RecipeDetails.svelte b/packages/frontend/src/lib/RecipeDetails.svelte index 01d7de4d3..e39db1392 100644 --- a/packages/frontend/src/lib/RecipeDetails.svelte +++ b/packages/frontend/src/lib/RecipeDetails.svelte @@ -1,30 +1,28 @@ + +
+
+ {#if task.state === 'success'} + + + + {:else if task.state === 'loading'} + + + + + {:else} + + + + {/if} +
+ + {task.name} + {#if task.progress}({Math.floor(task.progress)}%){/if} + +
diff --git a/packages/frontend/src/lib/table/environment/ColumnActions.svelte b/packages/frontend/src/lib/table/environment/ColumnActions.svelte index 9132ffcf7..1533bd870 100644 --- a/packages/frontend/src/lib/table/environment/ColumnActions.svelte +++ b/packages/frontend/src/lib/table/environment/ColumnActions.svelte @@ -1,28 +1,11 @@ -{#if object.status === 'stopping' || object.status === 'removing'} -
-{:else} - - - -{/if} + diff --git a/packages/frontend/src/lib/table/environment/ColumnName.svelte b/packages/frontend/src/lib/table/environment/ColumnName.svelte index d5bb676be..a6f25ed87 100644 --- a/packages/frontend/src/lib/table/environment/ColumnName.svelte +++ b/packages/frontend/src/lib/table/environment/ColumnName.svelte @@ -1,8 +1,8 @@ diff --git a/packages/frontend/src/lib/table/environment/ColumnStatus.svelte b/packages/frontend/src/lib/table/environment/ColumnStatus.svelte index df5a541e3..c26cd059f 100644 --- a/packages/frontend/src/lib/table/environment/ColumnStatus.svelte +++ b/packages/frontend/src/lib/table/environment/ColumnStatus.svelte @@ -1,8 +1,19 @@ -
- {object.status} -
+{#if task} +
+ +
+{/if} diff --git a/packages/frontend/src/pages/Environments.svelte b/packages/frontend/src/pages/Environments.svelte index 263753995..7a3dee4ba 100644 --- a/packages/frontend/src/pages/Environments.svelte +++ b/packages/frontend/src/pages/Environments.svelte @@ -7,21 +7,34 @@ import { environmentStates } from '/@/stores/environment-states'; import ColumnName from '../lib/table/environment/ColumnName.svelte'; import ColumnActions from '../lib/table/environment/ColumnActions.svelte'; import ColumnStatus from '../lib/table/environment/ColumnStatus.svelte'; +import { recipes } from '/@/stores/recipe'; +import type { EnvironmentCell } from './environments'; -const columns: Column[] = [ - new Column('Name', { width: '3fr', renderer: ColumnName }), - new Column('Status', { width: '80px', renderer: ColumnStatus }), - new Column('Actions', { align: 'right', width: '80px', renderer: ColumnActions }), +let data: EnvironmentCell[]; + +$: recipesArray = Array.from($recipes.values()); + +$: data = $environmentStates.map((env: EnvironmentState) => ({ + recipeId: env.recipeId, + modelId: env.modelId, + envState: env, + tasks: recipesArray.find(r => r.recipeId === env.recipeId)?.tasks, +})); + +const columns: Column[] = [ + new Column('Name', { width: '3fr', renderer: ColumnName }), + new Column('Status', { width: '1fr', renderer: ColumnStatus }), + new Column('Actions', { align: 'right', width: '120px', renderer: ColumnActions }), ]; -const row = new Row({}); +const row = new Row({});
- {#if $environmentStates.length > 0} -
+ {#if data.length > 0} +
{:else}
There is no environment yet
{/if} diff --git a/packages/frontend/src/pages/Models.spec.ts b/packages/frontend/src/pages/Models.spec.ts index fcffaafe9..236a86b0d 100644 --- a/packages/frontend/src/pages/Models.spec.ts +++ b/packages/frontend/src/pages/Models.spec.ts @@ -55,7 +55,6 @@ test('should display There is no model yet and have a task running', async () => const map = new Map(); map.set('random', { recipeId: 'random-recipe-id', - state: 'loading', tasks: [ { id: 'random', diff --git a/packages/frontend/src/pages/Recipe.spec.ts b/packages/frontend/src/pages/Recipe.spec.ts index 3668b4464..fb3dd135c 100644 --- a/packages/frontend/src/pages/Recipe.spec.ts +++ b/packages/frontend/src/pages/Recipe.spec.ts @@ -30,6 +30,7 @@ const mocks = vi.hoisted(() => { getPullingStatusesMock: vi.fn(), pullApplicationMock: vi.fn(), telemetryLogUsageMock: vi.fn(), + getEnvironmentsStateMock: vi.fn(), }; }); @@ -40,6 +41,7 @@ vi.mock('../utils/client', async () => { getPullingStatuses: mocks.getPullingStatusesMock, pullApplication: mocks.pullApplicationMock, telemetryLogUsage: mocks.telemetryLogUsageMock, + getEnvironmentsState: mocks.getEnvironmentsStateMock, }, rpcBrowser: { subscribe: () => { @@ -153,6 +155,7 @@ beforeEach(() => { test('should display recipe information', async () => { vi.mocked(catalogStore).catalog = readable(initialCatalog); + mocks.getEnvironmentsStateMock.mockResolvedValue([]); mocks.getPullingStatusesMock.mockResolvedValue(new Map()); render(Recipe, { recipeId: 'recipe 1', @@ -163,6 +166,7 @@ test('should display recipe information', async () => { }); test('should display updated recipe information', async () => { + mocks.getEnvironmentsStateMock.mockResolvedValue([]); const customCatalog = writable(initialCatalog); vi.mocked(catalogStore).catalog = customCatalog; mocks.getPullingStatusesMock.mockResolvedValue(new Map()); @@ -179,6 +183,7 @@ test('should display updated recipe information', async () => { }); test('should send telemetry data', async () => { + mocks.getEnvironmentsStateMock.mockResolvedValue([]); vi.mocked(catalogStore).catalog = readable(initialCatalog); mocks.getPullingStatusesMock.mockResolvedValue(new Map()); mocks.pullApplicationMock.mockResolvedValue(undefined); diff --git a/packages/frontend/src/pages/environments.ts b/packages/frontend/src/pages/environments.ts new file mode 100644 index 000000000..91f15ac01 --- /dev/null +++ b/packages/frontend/src/pages/environments.ts @@ -0,0 +1,9 @@ +import type { EnvironmentState } from '@shared/src/models/IEnvironmentState'; +import type { Task } from '@shared/src/models/ITask'; + +export interface EnvironmentCell { + tasks?: Task[]; + envState: EnvironmentState; + recipeId: string; + modelId: string; +} diff --git a/packages/shared/src/models/IEnvironmentState.ts b/packages/shared/src/models/IEnvironmentState.ts index 7567b7e2f..1f00d66bb 100644 --- a/packages/shared/src/models/IEnvironmentState.ts +++ b/packages/shared/src/models/IEnvironmentState.ts @@ -18,10 +18,8 @@ import type { PodInfo } from '@podman-desktop/api'; -export type EnvironmentStatus = 'none' | 'running' | 'starting' | 'stopping' | 'removing' | 'error' | 'unknown'; - export interface EnvironmentState { recipeId: string; + modelId: string; pod: PodInfo; - status: EnvironmentStatus; } diff --git a/packages/shared/src/models/IRecipeStatus.ts b/packages/shared/src/models/IRecipeStatus.ts index dad911de1..580322c5d 100644 --- a/packages/shared/src/models/IRecipeStatus.ts +++ b/packages/shared/src/models/IRecipeStatus.ts @@ -1,9 +1,6 @@ import type { Task } from './ITask'; -export type RecipeStatusState = 'none' | 'loading' | 'pulled' | 'running' | 'error'; - export interface RecipeStatus { recipeId: string; tasks: Task[]; - state: RecipeStatusState; }