Skip to content

Commit

Permalink
support extension restart and manual deletion of pod (#335)
Browse files Browse the repository at this point in the history
* support restart and manual delete

* add unit test

* change delete button to stop + delete stopped pod at startup + support manually stopped pod

* support machine stopped

* fix Delete/Stop message
  • Loading branch information
feloy authored Feb 16, 2024
1 parent ab0a1a8 commit 193b3b0
Show file tree
Hide file tree
Showing 7 changed files with 139 additions and 38 deletions.
3 changes: 3 additions & 0 deletions packages/backend/src/managers/applicationManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ describe('pullApplication', () => {
mockForPullApplication({
recipeFolderExists: false,
});
mocks.listPodsMock.mockResolvedValue([]);
vi.spyOn(modelsManager, 'isModelOnDisk').mockReturnValue(false);
mocks.performDownloadMock.mockResolvedValue('path');
const recipe: Recipe = {
Expand Down Expand Up @@ -306,6 +307,7 @@ describe('pullApplication', () => {
mockForPullApplication({
recipeFolderExists: true,
});
mocks.listPodsMock.mockResolvedValue([]);
vi.spyOn(modelsManager, 'isModelOnDisk').mockReturnValue(false);
mocks.performDownloadMock.mockResolvedValue('path');
const recipe: Recipe = {
Expand Down Expand Up @@ -334,6 +336,7 @@ describe('pullApplication', () => {
mockForPullApplication({
recipeFolderExists: true,
});
mocks.listPodsMock.mockResolvedValue([]);
vi.spyOn(modelsManager, 'isModelOnDisk').mockReturnValue(true);
vi.spyOn(modelsManager, 'getLocalModelPath').mockReturnValue('path');
const recipe: Recipe = {
Expand Down
79 changes: 55 additions & 24 deletions packages/backend/src/managers/applicationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ export class ApplicationManager {
// Map recipeId => EnvironmentState
#environments: Map<string, EnvironmentState>;

protectTasks: Set<string> = new Set();

constructor(
private appUserDirectory: string,
private git: GitManager,
Expand Down Expand Up @@ -137,6 +139,11 @@ export class ApplicationManager {
taskUtil,
);

// first delete any existing pod with matching labels
if (await this.hasEnvironmentPod(recipe.id)) {
await this.deleteEnvironment(recipe.id);
}

// create a pod containing all the containers to run the application
const podInfo = await this.createApplicationPod(recipe, model, images, modelPath, taskUtil);

Expand Down Expand Up @@ -607,6 +614,16 @@ export class ApplicationManager {

this.podmanConnection.onMachineStop(() => {
// Podman Machine has been stopped, we consider all recipe pods are stopped

for (const recipeId of this.#environments.keys()) {
const taskUtil = new RecipeStatusUtils(recipeId, this.recipeStatusRegistry);
taskUtil.setTask({
id: `stopped-${recipeId}`,
state: 'success',
name: `Application stopped manually`,
});
}

this.#environments.clear();
this.sendEnvironmentState();
});
Expand Down Expand Up @@ -649,6 +666,13 @@ export class ApplicationManager {
}
this.#environments.delete(recipeId);
this.sendEnvironmentState();

const taskUtil = new RecipeStatusUtils(recipeId, this.recipeStatusRegistry);
taskUtil.setTask({
id: `stopped-${recipeId}`,
state: 'success',
name: `Application stopped manually`,
});
}

forgetPodById(podId: string) {
Expand All @@ -665,6 +689,18 @@ export class ApplicationManager {
}
this.#environments.delete(recipeId);
this.sendEnvironmentState();

const protect = this.protectTasks.has(podId);
if (!protect) {
const taskUtil = new RecipeStatusUtils(recipeId, this.recipeStatusRegistry);
taskUtil.setTask({
id: `stopped-${recipeId}`,
state: 'success',
name: `Application stopped manually`,
});
} else {
this.protectTasks.delete(podId);
}
}

updateEnvironmentState(recipeId: string, state: EnvironmentState): void {
Expand Down Expand Up @@ -700,45 +736,31 @@ export class ApplicationManager {
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',
error: 'error stopping the pod. Please try to stop and 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`,
});
this.protectTasks.add(envPod.Id);
await containerEngine.removePod(envPod.engineId, envPod.Id);
taskUtil.setTask({
id: `removing-${recipeId}`,
id: `stopping-${recipeId}`,
state: 'success',
name: `Application removed`,
name: `Application stopped`,
});
} 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`,
name: `Error stopping application`,
});
throw err;
}
Expand All @@ -754,16 +776,25 @@ export class ApplicationManager {
}

async getEnvironmentPod(recipeId: string): Promise<PodInfo> {
const envPod = await this.queryPod(recipeId);
if (!envPod) {
throw new Error(`no pod found with recipe Id ${recipeId}`);
}
return envPod;
}

async hasEnvironmentPod(recipeId: string): Promise<boolean> {
const envPod = await this.queryPod(recipeId);
return !!envPod;
}

async queryPod(recipeId: string): Promise<PodInfo | undefined> {
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;
return pods.find(pod => LABEL_RECIPE_ID in pod.Labels && pod.Labels[LABEL_RECIPE_ID] === recipeId);
}
}
2 changes: 1 addition & 1 deletion packages/backend/src/studio-api-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ export class StudioApiImpl implements StudioAPI {
// Do not wait on the promise as the api would probably timeout before the user answer.
podmanDesktopApi.window
.showWarningMessage(
`Delete the environment "${recipe.name}"? This will delete the containers running the application and model.`,
`Stop the environment "${recipe.name}"? This will delete the containers running the application and model.`,
'Confirm',
'Cancel',
)
Expand Down
16 changes: 8 additions & 8 deletions packages/frontend/src/lib/EnvironmentActions.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { faPlay, faRotateForward, faTrash } from '@fortawesome/free-solid-svg-icons';
import { faPlay, faRotateForward, faStop } from '@fortawesome/free-solid-svg-icons';
import ListItemButtonIcon from '/@/lib/button/ListItemButtonIcon.svelte';
import { studioClient } from '/@/utils/client';
import type { EnvironmentState } from '@shared/src/models/IEnvironmentState';
Expand All @@ -17,7 +17,7 @@ function startEnvironment() {
});
}
function deleteEnvironment() {
function stopEnvironment() {
studioClient.requestRemoveEnvironment(recipeId).catch(err => {
console.error(`Something went wrong while trying to stop environment: ${String(err)}.`);
});
Expand All @@ -34,16 +34,16 @@ function restartEnvironment() {
icon="{faPlay}"
onClick="{() => startEnvironment()}"
title="Start Environment"
enabled="{!object?.pod && !runningTask}" />
enabled="{(!object?.pod || object?.pod.Status !== 'Running') && !runningTask}" />

<ListItemButtonIcon
icon="{faTrash}"
onClick="{() => deleteEnvironment()}"
title="Delete Environment"
enabled="{!!object?.pod}" />
icon="{faStop}"
onClick="{() => stopEnvironment()}"
title="Stop Environment"
enabled="{object?.pod.Status === 'Running'}" />

<ListItemButtonIcon
icon="{faRotateForward}"
onClick="{() => restartEnvironment()}"
title="Restart Environment"
enabled="{!!object?.pod}" />
enabled="{object?.pod.Status === 'Running'}" />
2 changes: 1 addition & 1 deletion packages/frontend/src/lib/RecipeDetails.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const toggle = () => {

<div class="w-full bg-charcoal-600 rounded-md p-4">
<div class="flex flex-row items-center">
{#if envState}
{#if envState && envState.pod.Status === 'Running'}
<div class="grow whitespace-nowrap overflow-hidden text-ellipsis text-sm text-gray-300">
{envState.pod.Name}
</div>
Expand Down
65 changes: 65 additions & 0 deletions packages/frontend/src/lib/table/environment/ColumnStatus.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**********************************************************************
* 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 '@testing-library/jest-dom/vitest';
import { test, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import type { EnvironmentCell } from '/@/pages/environments';
import ColumnStatus from './ColumnStatus.svelte';

test('display Pod Running when no task', async () => {
const obj = {
recipeId: 'recipe 1',
envState: {
pod: {
Id: 'pod-1',
},
},
} as EnvironmentCell;
render(ColumnStatus, { object: obj });

const text = screen.getByText('Pod running');
expect(text).toBeInTheDocument();
});

test('display latest task', async () => {
const obj = {
recipeId: 'recipe 1',
envState: {
pod: {
Id: 'pod-1',
},
},
tasks: [
{
id: 'task1',
name: 'task 1 done',
state: 'success',
},
{
id: 'task2',
name: 'task 2 running',
state: 'loading',
},
],
} as EnvironmentCell;
render(ColumnStatus, { object: obj });

const text = screen.getByText('task 2 running');
expect(text).toBeInTheDocument();
});
10 changes: 6 additions & 4 deletions packages/frontend/src/lib/table/environment/ColumnStatus.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ $: {
}
</script>

{#if task}
<div class="text-sm text-gray-700">
<div class="text-sm text-gray-700">
{#if task}
<TaskItem task="{task}" />
</div>
{/if}
{:else if !!object.envState.pod}
Pod running
{/if}
</div>

0 comments on commit 193b3b0

Please sign in to comment.