Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

adopt playground containers #200

Merged
merged 7 commits into from
Feb 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"views": {
"icons/containersList": [
{
"when": "ia-studio-model in containerLabelKeys",
"when": "ai-studio-model-id in containerLabelKeys",
"icon": "${brain-icon}"
}
]
Expand Down
5 changes: 4 additions & 1 deletion packages/backend/src/managers/playground.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { beforeEach, expect, test, vi } from 'vitest';
import { PlayGroundManager } from './playground';
import type { ImageInfo, Webview } from '@podman-desktop/api';
import type { ContainerRegistry } from '../registries/ContainerRegistry';
import type { PodmanConnection } from './podmanConnection';

const mocks = vi.hoisted(() => ({
postMessage: vi.fn(),
Expand Down Expand Up @@ -64,6 +65,7 @@ beforeEach(() => {
postMessage: mocks.postMessage,
} as unknown as Webview,
containerRegistryMock,
{} as PodmanConnection,
);
});

Expand Down Expand Up @@ -123,7 +125,8 @@ test('startPlayground should download image if not present then create container
},
Image: 'image1',
Labels: {
'ia-studio-model': 'model1',
'ai-studio-model-id': 'model1',
'ai-studio-model-port': '8085',
},
});
});
Expand Down
53 changes: 47 additions & 6 deletions packages/backend/src/managers/playground.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@
***********************************************************************/

import {
provider,
containerEngine,
type Webview,
type ProviderContainerConnection,
type ImageInfo,
type ProviderContainerConnection,
provider,
} from '@podman-desktop/api';
import type { LocalModelInfo } from '@shared/src/models/ILocalModelInfo';
import type { ModelResponse } from '@shared/src/models/IModelResponse';
Expand All @@ -33,6 +33,10 @@ import type { QueryState } from '@shared/src/models/IPlaygroundQueryState';
import { MSG_NEW_PLAYGROUND_QUERIES_STATE, MSG_PLAYGROUNDS_STATE_UPDATE } from '@shared/Messages';
import type { PlaygroundState, PlaygroundStatus } from '@shared/src/models/IPlaygroundState';
import type { ContainerRegistry } from '../registries/ContainerRegistry';
import type { PodmanConnection } from './podmanConnection';

const LABEL_MODEL_ID = 'ai-studio-model-id';
const LABEL_MODEL_PORT = 'ai-studio-model-port';

// TODO: this should not be hardcoded
const PLAYGROUND_IMAGE = 'quay.io/bootsy/playground:v0';
Expand All @@ -55,12 +59,48 @@ export class PlayGroundManager {
constructor(
private webview: Webview,
private containerRegistry: ContainerRegistry,
private podmanConnection: PodmanConnection,
) {
this.playgrounds = new Map<string, PlaygroundState>();
this.queries = new Map<number, QueryState>();
}

async selectImage(connection: ProviderContainerConnection, image: string): Promise<ImageInfo | undefined> {
adoptRunningPlaygrounds() {
this.podmanConnection.startupSubscribe(() => {
containerEngine
.listContainers()
.then(containers => {
const playgroundContainers = containers.filter(
c => LABEL_MODEL_ID in c.Labels && LABEL_MODEL_PORT in c.Labels && c.State === 'running',
);
for (const containerToAdopt of playgroundContainers) {
const modelId = containerToAdopt.Labels[LABEL_MODEL_ID];
if (this.playgrounds.has(modelId)) {
continue;
}
const modelPort = parseInt(containerToAdopt.Labels[LABEL_MODEL_PORT], 10);
if (isNaN(modelPort)) {
continue;
}
const state: PlaygroundState = {
modelId,
status: 'running',
container: {
containerId: containerToAdopt.Id,
engineId: containerToAdopt.engineId,
port: modelPort,
},
};
this.updatePlaygroundState(modelId, state);
}
})
.catch((err: unknown) => {
console.error('error during adoption of existing playground containers', err);
});
});
}

async selectImage(image: string): Promise<ImageInfo | undefined> {
const images = (await containerEngine.listImages()).filter(im => im.RepoTags?.some(tag => tag === image));
return images.length > 0 ? images[0] : undefined;
}
Expand Down Expand Up @@ -110,10 +150,10 @@ export class PlayGroundManager {
throw new Error('Unable to find an engine to start playground');
}

let image = await this.selectImage(connection, PLAYGROUND_IMAGE);
let image = await this.selectImage(PLAYGROUND_IMAGE);
if (!image) {
await containerEngine.pullImage(connection.connection, PLAYGROUND_IMAGE, () => {});
image = await this.selectImage(connection, PLAYGROUND_IMAGE);
image = await this.selectImage(PLAYGROUND_IMAGE);
if (!image) {
this.setPlaygroundStatus(modelId, 'error');
throw new Error(`Unable to find ${PLAYGROUND_IMAGE} image`);
Expand Down Expand Up @@ -143,7 +183,8 @@ export class PlayGroundManager {
},
},
Labels: {
'ia-studio-model': modelId,
[LABEL_MODEL_ID]: modelId,
[LABEL_MODEL_PORT]: `${freePort}`,
},
Env: [`MODEL_PATH=/models/${path.basename(modelPath)}`],
Cmd: ['--models-path', '/models', '--context-size', '700', '--threads', '4'],
Expand Down
65 changes: 65 additions & 0 deletions packages/backend/src/managers/podmanConnection.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 { type RegisterContainerConnectionEvent, provider } from '@podman-desktop/api';

type startupHandle = () => void;

export class PodmanConnection {
#firstFound = false;
#toExecuteAtStartup: startupHandle[] = [];

init(): void {
// In case the extension has not yet registered, we listen for new registrations
// and retain the first started podman provider
const disposable = provider.onDidRegisterContainerConnection((e: RegisterContainerConnectionEvent) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we listening on connection registration andnot connection start/stop events ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this feature, I only want to list containers started before Podman Desktop is started, and I want to be informed when the podman extension is registered (or the listContainers would return an empty list if we try to retrieve the list before).

(note that there is a registries/ContainerRegistry which is watching at events on a specific container, which we may merge with this class at some point)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't test it so maybe i'm wrong but looking at the code it looks to me that it initializes the connection when the studio is activated if there is atleast one podman machine that is started and then it starts listening to if a machine gets registered.
So if i start my desktop having my podman machine stopped, the connection is not set as there is no podman machine running. If i start my podman machine in a second moment it does not trigger its initialization bc it only listen to machine registration.
So if i start the playground or any other feature that requires to retrieve the active connection it fails, no? But i have my podman machine running fine, i just started it after my desktop was activated

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you are probably right, I'll look at supporting this scenario too. Thanks

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finally, I prefer for now to remove this exposure and use of connection from the PodmcnConnection class, as it is not part of what is needed to fix the targeted issue, which is only related to getting a connection to the provider at startup.

I'll work at this refactoring as part of another PR, if necessary.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is a demo on Linux to see what you can test:

adopt-playground-2024-02-02_10.29.49.mp4

My other scenario is, you are using the playground, turn off the machine bc you switch to another or whatever. ai-studio should be able to remove the adopted container as it is stopped now, then you turn the machine on again, the container is there stopped, yo start it, ai-studio should be able to adopt it again.

For now, we are not checking when a machine is stopped, to mark playground as stopped. It would be an extension of the PR #194 (and not particularly part of this PR as it is a different logic).

As said in my previous message, when you stop a playground container, it is also deleted (because marked as AUTOREMOVE). So you will never have a playground container (not even stopped) when you start a podman machine.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you stop the machine the container will be there, not deleted and you can restart it once you restart the machine.

image

Copy link
Contributor Author

@feloy feloy Feb 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIK, it is not the expected behaviour for a container marked as auto-removable. In Inspect of this container, is it marked as this?

  "HostConfig": {
    [...]
    "AutoRemove": true,

I'm doing the same test in my Windows machine, and the container is not present anymore

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not that I don't want to support these stopped containers, but if we consider having stopped playground containers, this adds a third state for the playground container in the UI (paused in addition to not-running and running), which we would also have to manage in the UI (set the playground in pause, etc). That would be a bigger change than just detecting a stopped container is started again.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the slow response. Done it again now and i confirm that the Autoremove flag is there

if (e.connection.type !== 'podman' || e.connection.status() !== 'started') {
return;
}
if (this.#firstFound) {
return;
}
this.#firstFound = true;
for (const f of this.#toExecuteAtStartup) {
f();
}
this.#toExecuteAtStartup = [];
disposable.dispose();
});

// In case at least one extension has already registered, we get one started podman provider
const engines = provider
.getContainerConnections()
.filter(connection => connection.connection.type === 'podman')
.filter(connection => connection.connection.status() === 'started');
if (engines.length > 0) {
disposable.dispose();
this.#firstFound = true;
}
}

// startupSubscribe registers f to be executed when a podman container provider
// registers, or immediately if already registered
startupSubscribe(f: startupHandle): void {
if (this.#firstFound) {
f();
} else {
this.#toExecuteAtStartup.push(f);
}
}
}
12 changes: 12 additions & 0 deletions packages/backend/src/studio.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ const mockedExtensionContext = {

const studio = new Studio(mockedExtensionContext);

const mocks = vi.hoisted(() => ({
listContainers: vi.fn(),
getContainerConnections: vi.fn(),
}));

vi.mock('@podman-desktop/api', async () => {
return {
Uri: class {
Expand All @@ -48,6 +53,11 @@ vi.mock('@podman-desktop/api', async () => {
},
containerEngine: {
onEvent: vi.fn(),
listContainers: mocks.listContainers,
},
provider: {
onDidRegisterContainerConnection: vi.fn(),
getContainerConnections: mocks.getContainerConnections,
},
};
});
Expand All @@ -66,6 +76,8 @@ afterEach(() => {
});

test('check activate ', async () => {
mocks.listContainers.mockReturnValue([]);
mocks.getContainerConnections.mockReturnValue([]);
vi.spyOn(fs.promises, 'readFile').mockImplementation(() => {
return Promise.resolve('<html></html>');
});
Expand Down
7 changes: 6 additions & 1 deletion packages/backend/src/studio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import path from 'node:path';
import os from 'os';
import fs from 'node:fs';
import { ContainerRegistry } from './registries/ContainerRegistry';
import { PodmanConnection } from './managers/podmanConnection';

// TODO: Need to be configured
export const AI_STUDIO_FOLDER = path.join('podman-desktop', 'ai-studio');
Expand Down Expand Up @@ -104,9 +105,11 @@ export class Studio {

this.rpcExtension = new RpcExtension(this.#panel.webview);
const gitManager = new GitManager();

const podmanConnection = new PodmanConnection();
const taskRegistry = new TaskRegistry();
const recipeStatusRegistry = new RecipeStatusRegistry(taskRegistry, this.#panel.webview);
this.playgroundManager = new PlayGroundManager(this.#panel.webview, containerRegistry);
this.playgroundManager = new PlayGroundManager(this.#panel.webview, containerRegistry, podmanConnection);
// Create catalog manager, responsible for loading the catalog files and watching for changes
this.catalogManager = new CatalogManager(appUserDirectory, this.#panel.webview);
this.modelsManager = new ModelsManager(appUserDirectory, this.#panel.webview, this.catalogManager);
Expand All @@ -128,6 +131,8 @@ export class Studio {

await this.catalogManager.loadCatalog();
await this.modelsManager.loadLocalModels();
podmanConnection.init();
this.playgroundManager.adoptRunningPlaygrounds();

// Register the instance
this.rpcExtension.registerInstance<StudioApiImpl>(StudioApiImpl, this.studioApi);
Expand Down
Loading