Skip to content

Commit

Permalink
feat(application): adding start stop actions
Browse files Browse the repository at this point in the history
Signed-off-by: axel7083 <[email protected]>
  • Loading branch information
axel7083 committed May 27, 2024
1 parent 27091ff commit e42f5e8
Show file tree
Hide file tree
Showing 8 changed files with 300 additions and 79 deletions.
40 changes: 18 additions & 22 deletions packages/backend/src/managers/applicationManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,8 @@ const mocks = vi.hoisted(() => {
parseYamlFileMock: vi.fn(),
listImagesMock: vi.fn(),
getImageInspectMock: vi.fn(),
createPodMock: vi.fn(),
createContainerMock: vi.fn(),
startContainerMock: vi.fn(),
startPod: vi.fn(),
inspectContainerMock: vi.fn(),
logUsageMock: vi.fn(),
logErrorMock: vi.fn(),
Expand All @@ -69,8 +67,6 @@ const mocks = vi.hoisted(() => {
startupSubscribeMock: vi.fn(),
onMachineStopMock: vi.fn(),
listContainersMock: vi.fn(),
stopPodMock: vi.fn(),
removePodMock: vi.fn(),
performDownloadMock: vi.fn(),
getTargetMock: vi.fn(),
onEventDownloadMock: vi.fn(),
Expand Down Expand Up @@ -104,16 +100,12 @@ vi.mock('@podman-desktop/api', () => ({
containerEngine: {
listImages: mocks.listImagesMock,
getImageInspect: mocks.getImageInspectMock,
createPod: mocks.createPodMock,
createContainer: mocks.createContainerMock,
startContainer: mocks.startContainerMock,
startPod: mocks.startPod,
inspectContainer: mocks.inspectContainerMock,
pullImage: mocks.pullImageMock,
stopContainer: mocks.stopContainerMock,
listContainers: mocks.listContainersMock,
stopPod: mocks.stopPodMock,
removePod: mocks.removePodMock,
},
Disposable: {
create: vi.fn(),
Expand Down Expand Up @@ -146,6 +138,10 @@ const podManager = {
getPodsWithLabels: vi.fn(),
getHealth: vi.fn(),
getPod: vi.fn(),
createPod: vi.fn(),
stopPod: vi.fn(),
removePod: vi.fn(),
startPod: vi.fn(),
} as unknown as PodManager;

const localRepositoryRegistry = {
Expand Down Expand Up @@ -235,7 +231,7 @@ describe('pullApplication', () => {
Id: 'id1',
},
]);
mocks.createPodMock.mockResolvedValue({
vi.mocked(podManager.createPod).mockResolvedValue({
engineId: 'engine',
Id: 'id',
});
Expand Down Expand Up @@ -723,12 +719,12 @@ describe('createPod', async () => {
vi.spyOn(portsUtils, 'getPortsInfo').mockResolvedValueOnce('9000');
vi.spyOn(portsUtils, 'getPortsInfo').mockResolvedValueOnce('9001');
vi.spyOn(portsUtils, 'getPortsInfo').mockResolvedValueOnce('9002');
mocks.createPodMock.mockResolvedValue({
vi.mocked(podManager.createPod).mockResolvedValue({
Id: 'podId',
engineId: 'engineId',
});
await manager.createPod({ id: 'recipe-id' } as Recipe, { id: 'model-id' } as ModelInfo, images);
expect(mocks.createPodMock).toBeCalledWith({
expect(podManager.createPod).toBeCalledWith({
name: expect.anything(),
portmappings: [
{
Expand Down Expand Up @@ -873,7 +869,7 @@ describe('runApplication', () => {
const waitContainerIsRunningMock = vi.spyOn(manager, 'waitContainerIsRunning').mockResolvedValue(undefined);
vi.spyOn(utils, 'timeout').mockResolvedValue();
await manager.runApplication(pod);
expect(mocks.startPod).toBeCalledWith(pod.engineId, pod.Id);
expect(podManager.startPod).toBeCalledWith(pod.engineId, pod.Id);
expect(waitContainerIsRunningMock).toBeCalledWith(pod.engineId, {
Id: 'dummyContainerId',
});
Expand Down Expand Up @@ -1150,7 +1146,7 @@ describe('pod detection', async () => {
const sendApplicationStateSpy = vi.spyOn(manager, 'notify').mockResolvedValue();
manager.adoptRunningApplications();
await new Promise(resolve => setTimeout(resolve, 10));
expect(sendApplicationStateSpy).toHaveBeenCalledTimes(2);
expect(sendApplicationStateSpy).toHaveBeenCalledTimes(1);
});

test('onPodRemove updates the applications state by removing the removed pod', async () => {
Expand Down Expand Up @@ -1198,7 +1194,7 @@ describe('pod detection', async () => {
});
});

test('deleteApplication calls stopPod and removePod', async () => {
test('removeApplication calls stopPod and removePod', async () => {
vi.mocked(podManager.findPodByLabelsValues).mockResolvedValue({
engineId: 'engine-1',
Id: 'pod-1',
Expand All @@ -1207,12 +1203,12 @@ describe('pod detection', async () => {
'ai-lab-model-id': 'model-id-1',
},
} as unknown as PodInfo);
await manager.deleteApplication('recipe-id-1', 'model-id-1');
expect(mocks.stopPodMock).toHaveBeenCalledWith('engine-1', 'pod-1');
expect(mocks.removePodMock).toHaveBeenCalledWith('engine-1', 'pod-1');
await manager.removeApplication('recipe-id-1', 'model-id-1');
expect(podManager.stopPod).toHaveBeenCalledWith('engine-1', 'pod-1');
expect(podManager.removePod).toHaveBeenCalledWith('engine-1', 'pod-1');
});

test('deleteApplication calls stopPod and removePod even if stopPod fails because pod already stopped', async () => {
test('removeApplication calls stopPod and removePod even if stopPod fails because pod already stopped', async () => {
vi.mocked(podManager.findPodByLabelsValues).mockResolvedValue({
engineId: 'engine-1',
Id: 'pod-1',
Expand All @@ -1221,10 +1217,10 @@ describe('pod detection', async () => {
'ai-lab-model-id': 'model-id-1',
},
} as unknown as PodInfo);
mocks.stopPodMock.mockRejectedValue('something went wrong, pod already stopped...');
await manager.deleteApplication('recipe-id-1', 'model-id-1');
expect(mocks.stopPodMock).toHaveBeenCalledWith('engine-1', 'pod-1');
expect(mocks.removePodMock).toHaveBeenCalledWith('engine-1', 'pod-1');
vi.mocked(podManager.stopPod).mockRejectedValue('something went wrong, pod already stopped...');
await manager.removeApplication('recipe-id-1', 'model-id-1');
expect(podManager.stopPod).toHaveBeenCalledWith('engine-1', 'pod-1');
expect(podManager.removePod).toHaveBeenCalledWith('engine-1', 'pod-1');
});

test('adoptRunningApplications should check pods health', async () => {
Expand Down
103 changes: 74 additions & 29 deletions packages/backend/src/managers/applicationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ export class ApplicationManager extends Publisher<ApplicationState[]> implements

// first delete any existing pod with matching labels
if (await this.hasApplicationPod(recipe.id, model.id)) {
await this.deleteApplication(recipe.id, model.id);
await this.removeApplication(recipe.id, model.id);
}

// create a pod containing all the containers to run the application
Expand All @@ -213,7 +213,7 @@ export class ApplicationManager extends Publisher<ApplicationState[]> implements
const task = this.taskRegistry.createTask('Starting AI App', 'loading', labels);

// it starts the pod
await containerEngine.startPod(podInfo.engineId, podInfo.Id);
await this.podManager.startPod(podInfo.engineId, podInfo.Id);

// check if all containers have started successfully
for (const container of podInfo.Containers ?? []) {
Expand Down Expand Up @@ -387,7 +387,7 @@ export class ApplicationManager extends Publisher<ApplicationState[]> implements
if (appPorts.length) {
labels[LABEL_APP_PORTS] = appPorts.join(',');
}
const { engineId, Id } = await containerEngine.createPod({
const { engineId, Id } = await this.podManager.createPod({
name: getRandomName(`pod-${sampleAppImageInfo.appName}`),
portmappings: portmappings,
labels,
Expand All @@ -396,6 +396,56 @@ export class ApplicationManager extends Publisher<ApplicationState[]> implements
return this.podManager.getPod(engineId, Id);
}

/**
* Stop the pod with matching recipeId and modelId
* @param recipeId
* @param modelId
*/
async stopApplication(recipeId: string, modelId: string): Promise<PodInfo> {
// clear existing tasks
this.clearTasks(recipeId, modelId);

// get the application pod
const appPod = await this.getApplicationPod(recipeId, modelId);
console.log(' stopApplication appPod', appPod);

// if the pod is already stopped skip
if (appPod.Status === 'stopped') {
return appPod;
}

// create a task to follow progress/error
const stoppingTask = this.taskRegistry.createTask(`Stopping AI App`, 'loading', {
'recipe-id': recipeId,
'model-id': modelId,
});

try {
await this.podManager.stopPod(appPod.engineId, appPod.Id);

stoppingTask.state = 'success';
stoppingTask.name = `AI App Stopped`;
} catch (err: unknown) {
stoppingTask.error = `Error removing the pod.: ${String(err)}`;
stoppingTask.name = 'Error stopping AI App';
} finally {
this.taskRegistry.updateTask(stoppingTask);
}
return appPod;
}

/**
* Utility method to start a pod using (recipeId, modelId)
* @param recipeId
* @param modelId
*/
async startApplication(recipeId: string, modelId: string): Promise<void> {
this.clearTasks(recipeId, modelId);
const pod = await this.getApplicationPod(recipeId, modelId);

return this.runApplication(pod);
}

private getConfigAndFilterContainers(
recipeBaseDir: string | undefined,
localFolder: string,
Expand Down Expand Up @@ -526,9 +576,6 @@ export class ApplicationManager extends Publisher<ApplicationState[]> implements
this.podmanConnection.onPodStart((pod: PodInfo) => {
this.adoptPod(pod);
});
this.podmanConnection.onPodStop((pod: PodInfo) => {
this.forgetPod(pod);
});
this.podmanConnection.onPodRemove((podId: string) => {
this.forgetPodById(podId);
});
Expand Down Expand Up @@ -671,47 +718,45 @@ export class ApplicationManager extends Publisher<ApplicationState[]> implements
return Array.from(this.#applications.values());
}

async deleteApplication(recipeId: string, modelId: string): Promise<void> {
private clearTasks(recipeId: string, modelId: string): void {
// clear any existing status / tasks related to the pair recipeId-modelId.
this.taskRegistry.deleteByLabels({
'recipe-id': recipeId,
'model-id': modelId,
});
}

const stoppingTask = this.taskRegistry.createTask(`Stopping AI App`, 'loading', {
/**
* Method that will stop then remove a pod corresponding to the recipe and model provided
* @param recipeId
* @param modelId
*/
async removeApplication(recipeId: string, modelId: string): Promise<void> {
const appPod = await this.stopApplication(recipeId, modelId);

const remoteTask = this.taskRegistry.createTask(`Removing AI App`, 'loading', {
'recipe-id': recipeId,
'model-id': modelId,
});
// protect the task
this.protectTasks.add(appPod.Id);

try {
const appPod = await this.getApplicationPod(recipeId, modelId);
try {
await containerEngine.stopPod(appPod.engineId, appPod.Id);
} catch (err: unknown) {
// continue when the pod is already stopped
if (!String(err).includes('pod already stopped')) {
stoppingTask.error = 'error stopping the pod. Please try to stop and remove the pod manually';
stoppingTask.name = 'Error stopping AI App';
this.taskRegistry.updateTask(stoppingTask);
throw err;
}
}
this.protectTasks.add(appPod.Id);
await containerEngine.removePod(appPod.engineId, appPod.Id);
await this.podManager.removePod(appPod.engineId, appPod.Id);

stoppingTask.state = 'success';
stoppingTask.name = `AI App stopped`;
remoteTask.state = 'success';
remoteTask.name = `AI App Removed`;
} catch (err: unknown) {
stoppingTask.error = 'error removing the pod. Please try to remove the pod manually';
stoppingTask.name = 'Error stopping AI App';
throw err;
remoteTask.error = 'error removing the pod. Please try to remove the pod manually';
remoteTask.name = 'Error stopping AI App';
} finally {
this.taskRegistry.updateTask(stoppingTask);
this.taskRegistry.updateTask(remoteTask);
}
}

async restartApplication(recipeId: string, modelId: string): Promise<void> {
const appPod = await this.getApplicationPod(recipeId, modelId);
await this.deleteApplication(recipeId, modelId);
await this.removeApplication(recipeId, modelId);
const recipe = this.catalogManager.getRecipeById(recipeId);
const model = this.catalogManager.getModelById(appPod.Labels[LABEL_MODEL_ID]);

Expand Down
30 changes: 29 additions & 1 deletion packages/backend/src/managers/recipes/PodManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,16 @@

import { beforeEach, describe, vi, expect, test } from 'vitest';
import { PodManager } from './PodManager';
import type { ContainerInspectInfo, PodInfo } from '@podman-desktop/api';
import type { ContainerInspectInfo, PodCreateOptions, PodInfo } from '@podman-desktop/api';
import { containerEngine } from '@podman-desktop/api';

vi.mock('@podman-desktop/api', () => ({
containerEngine: {
listPods: vi.fn(),
stopPod: vi.fn(),
removePod: vi.fn(),
startPod: vi.fn(),
createPod: vi.fn(),
inspectContainer: vi.fn(),
},
}));
Expand Down Expand Up @@ -206,3 +210,27 @@ describe('getPod', () => {
expect(pod.Id).toBe('pod-id-3');
});
});

test('stopPod should call containerEngine.stopPod', async () => {
await new PodManager().stopPod('dummy-engine-id', 'dummy-pod-id');
expect(containerEngine.stopPod).toHaveBeenCalledWith('dummy-engine-id', 'dummy-pod-id');
});

test('removePod should call containerEngine.removePod', async () => {
await new PodManager().removePod('dummy-engine-id', 'dummy-pod-id');
expect(containerEngine.removePod).toHaveBeenCalledWith('dummy-engine-id', 'dummy-pod-id');
});

test('startPod should call containerEngine.startPod', async () => {
await new PodManager().startPod('dummy-engine-id', 'dummy-pod-id');
expect(containerEngine.startPod).toHaveBeenCalledWith('dummy-engine-id', 'dummy-pod-id');
});

test('createPod should call containerEngine.createPod', async () => {
const options: PodCreateOptions = {
name: 'dummy-name',
portmappings: [],
};
await new PodManager().createPod(options);
expect(containerEngine.createPod).toHaveBeenCalledWith(options);
});
18 changes: 17 additions & 1 deletion packages/backend/src/managers/recipes/PodManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import type { Disposable, PodInfo } from '@podman-desktop/api';
import type { Disposable, PodCreateOptions, PodInfo } from '@podman-desktop/api';
import { containerEngine } from '@podman-desktop/api';
import type { PodHealth } from '@shared/src/models/IApplicationState';
import { getPodHealth } from '../../utils/podsUtils';
Expand Down Expand Up @@ -81,4 +81,20 @@ export class PodManager implements Disposable {
if (!result) throw new Error(`pod with engineId ${engineId} and Id ${Id} cannot be found.`);
return result;
}

async stopPod(engineId: string, id: string): Promise<void> {
return containerEngine.stopPod(engineId, id);
}

async removePod(engineId: string, id: string): Promise<void> {
return containerEngine.removePod(engineId, id);
}

async startPod(engineId: string, id: string): Promise<void> {
return containerEngine.startPod(engineId, id);
}

async createPod(podOptions: PodCreateOptions): Promise<{ engineId: string; Id: string }> {
return containerEngine.createPod(podOptions);
}
}
14 changes: 13 additions & 1 deletion packages/backend/src/studio-api-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,18 @@ export class StudioApiImpl implements StudioAPI {
return this.applicationManager.getApplicationsState();
}

async requestStartApplication(recipeId: string, modelId: string): Promise<void> {
this.applicationManager.startApplication(recipeId, modelId).catch((err: unknown) => {
console.error('Something went wrong while trying to start application', err);
});
}

async requestStopApplication(recipeId: string, modelId: string): Promise<void> {
this.applicationManager.stopApplication(recipeId, modelId).catch((err: unknown) => {
console.error('Something went wrong while trying to stop application', err);
});
}

async requestRemoveApplication(recipeId: string, modelId: string): Promise<void> {
const recipe = this.catalogManager.getRecipeById(recipeId);
// Do not wait on the promise as the api would probably timeout before the user answer.
Expand All @@ -282,7 +294,7 @@ export class StudioApiImpl implements StudioAPI {
)
.then((result: string | undefined) => {
if (result === 'Confirm') {
this.applicationManager.deleteApplication(recipeId, modelId).catch((err: unknown) => {
this.applicationManager.removeApplication(recipeId, modelId).catch((err: unknown) => {
console.error(`error deleting AI App's pod: ${String(err)}`);
podmanDesktopApi.window
.showErrorMessage(
Expand Down
Loading

0 comments on commit e42f5e8

Please sign in to comment.