Skip to content

Commit

Permalink
feat: manage default uri plugins for DevWorkspaces
Browse files Browse the repository at this point in the history
Signed-off-by: David Kwon <[email protected]>
  • Loading branch information
dkwon17 committed Dec 16, 2021
1 parent 731080d commit cd82a31
Show file tree
Hide file tree
Showing 6 changed files with 340 additions and 0 deletions.
2 changes: 2 additions & 0 deletions packages/dashboard-frontend/src/inversify.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from './services/workspace-client/devworkspace/devWorkspaceClient';
import { DevWorkspaceEditorProcessTheia } from './services/workspace-client/devworkspace/DevWorkspaceEditorProcessTheia';
import { DevWorkspaceEditorProcessCode } from './services/workspace-client/devworkspace/DevWorkspaceEditorProcessCode';
import { DevWorkspaceDefaultPluginsHandler } from './services/workspace-client/devworkspace/DevWorkspaceDefaultPluginsHandler';

const container = new Container();
const { lazyInject } = getDecorators(container);
Expand All @@ -38,5 +39,6 @@ container.bind(DevWorkspaceClient).toSelf().inSingletonScope();
container.bind(IDevWorkspaceEditorProcess).to(DevWorkspaceEditorProcessTheia).inSingletonScope();
container.bind(IDevWorkspaceEditorProcess).to(DevWorkspaceEditorProcessCode).inSingletonScope();
container.bind(AppAlerts).toSelf().inSingletonScope();
container.bind(DevWorkspaceDefaultPluginsHandler).toSelf().inSingletonScope();

export { container, lazyInject };
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2018-2021 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Red Hat, Inc. - initial API and implementation
*/

import axios from 'axios';
import common from '@eclipse-che/common';
import { prefix } from './const';

/**
* Returns an array of default plug-ins for the specified editor
*
* @param editorId The editor id to get default plug-ins for
* @returns Promise resolving with the array of default plug-ins for the specified editor
*/
export async function getDefaultPlugins(editorId: string): Promise<string[]> {
const url = `${prefix}/server-config/default-plugins`;
try {
const response = await axios.get(url);
const editorPlugins = response.data.find((element: { editor: string; plugins: string[] }) =>
element.editor.toLowerCase() === editorId,
);
return editorPlugins ? editorPlugins.plugins : [];
} catch (e) {
throw `Failed to fetch default plugins. ${common.helpers.errors.getMessage(e)}`;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* Copyright (c) 2018-2021 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Red Hat, Inc. - initial API and implementation
*/

import { injectable } from 'inversify';
import { api } from '@eclipse-che/common';
import * as ServerConfigApi from '../../dashboard-backend-client/serverConfigApi';
import { createHash } from 'crypto';
import devfileApi from '../../devfileApi';
import { V1alpha2DevWorkspaceSpecTemplateComponents } from '@devfile/api';
import * as DwApi from '../../dashboard-backend-client/devWorkspaceApi';

const DEFAULT_PLUGIN_ATTRIBUTE = 'che.eclipse.org/default-plugin';

/**
* This class manages the default plugins defined in
* the DevWorkspace's spec.template.components array.
*/
@injectable()
export class DevWorkspaceDefaultPluginsHandler {
public async handle(workspace: devfileApi.DevWorkspace, editor: string): Promise<void> {
const defaultPlugins = await ServerConfigApi.getDefaultPlugins(editor);
this.handleUriPlugins(workspace, defaultPlugins);
this.patchWorkspaceComponents(workspace);
}

private async handleUriPlugins(workspace: devfileApi.DevWorkspace, defaultPlugins: string[]) {
const defaultUriPlugins = new Set(
defaultPlugins.filter(plugin => {
if (this.isUri(plugin)) {
return true;
}
console.log(`Default plugin ${plugin} is not a uri. Ignoring.`);
return false;
}),
);

this.removeOldDefaultUriPlugins(workspace, defaultUriPlugins);

defaultUriPlugins.forEach(plugin => {
const hash = createHash('MD5').update(plugin).digest('hex').substring(0, 20).toLowerCase();
this.addDefaultPluginByUri(workspace, 'default-' + hash, plugin);
});
}

private isUri(str: string): boolean {
try {
new URL(str);
return true;
} catch (err) {
return false;
}
}

/**
* Checks if there are default plugins in the workspace that are not
* specified in defaultUriPlugins. If such plugins are found, this function
* removes them.
* @param workspace A devworkspace to remove old default plugins for
* @param defaultUriPlugins The set of current default plugins
*/
private removeOldDefaultUriPlugins(
workspace: devfileApi.DevWorkspace,
defaultUriPlugins: Set<string>,
) {
if (!workspace.spec.template.components) {
return;
}
const components = workspace.spec.template.components.filter(component => {
if (!this.isDefaultPluginComponent(component) || !component.plugin?.uri) {
// component is not a default uri plugin, keep component.
return true;
}
return defaultUriPlugins.has(component.plugin.uri);
});
workspace.spec.template.components = components;
}

/**
* Returns true if component is a default plugin managed by this class
* @param component The component to check
* @returns true if component is a default plugin managed by this class
*/
private isDefaultPluginComponent(component: V1alpha2DevWorkspaceSpecTemplateComponents): boolean {
return component.attributes && component.attributes[DEFAULT_PLUGIN_ATTRIBUTE]
? component.attributes[DEFAULT_PLUGIN_ATTRIBUTE].toString() === 'true'
: false;
}

/**
* Add a default plugin to the workspace by uri
* @param workspace A devworkspace
* @param pluginName The name of the plugin
* @param pluginUri The uri of the plugin
*/
private addDefaultPluginByUri(
workspace: devfileApi.DevWorkspace,
pluginName: string,
pluginUri: string,
) {
if (!workspace.spec.template.components) {
workspace.spec.template.components = [];
}

if (workspace.spec.template.components.find(component => component.name === pluginName)) {
// plugin already exists
return;
}

workspace.spec.template.components.push({
name: pluginName,
attributes: { [DEFAULT_PLUGIN_ATTRIBUTE]: true },
plugin: { uri: pluginUri },
});
}

private async patchWorkspaceComponents(workspace: devfileApi.DevWorkspace) {
const patch: api.IPatch[] = [
{
op: 'replace',
path: '/spec/template/components',
value: workspace.spec.template.components,
},
];
return DwApi.patchWorkspace(workspace.metadata.namespace, workspace.metadata.name, patch);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/*
* Copyright (c) 2018-2021 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Red Hat, Inc. - initial API and implementation
*/

import { container } from '../../../../inversify.config';
import { DevWorkspaceBuilder } from '../../../../store/__mocks__/devWorkspaceBuilder';
import { DevWorkspaceClient } from '../devWorkspaceClient';
import * as DwApi from '../../../dashboard-backend-client/devWorkspaceApi';
import * as ServerConfigApi from '../../../dashboard-backend-client/serverConfigApi';

describe('DevWorkspace client, start', () => {
let client: DevWorkspaceClient;

beforeEach(() => {
client = container.get(DevWorkspaceClient);
});

afterEach(() => {
jest.resetAllMocks();
});

it('should add default plugin uri', async () => {
const namespace = 'che';
const name = 'wksp-test';
const testWorkspace = new DevWorkspaceBuilder()
.withMetadata({
name,
namespace,
})
.build();

const defaultPluginUri = 'https://test.com/devfile.yaml';
const getDefaultPlugins = jest
.spyOn(ServerConfigApi, 'getDefaultPlugins')
.mockResolvedValueOnce([defaultPluginUri]);
const patchWorkspace = jest.spyOn(DwApi, 'patchWorkspace');
await client.onStart(testWorkspace, '');
expect(testWorkspace.spec.template.components!.length).toBe(1);
expect(testWorkspace.spec.template.components![0].plugin!.uri!).toBe(defaultPluginUri);
expect(
testWorkspace.spec.template.components![0].attributes!['che.eclipse.org/default-plugin'],
).toBe(true);
expect(getDefaultPlugins).toHaveBeenCalled();
expect(patchWorkspace).toHaveBeenCalled();
});

it('should remove default plugin uri when no default plugins exist', async () => {
const namespace = 'che';
const name = 'wksp-test';
const testWorkspace = new DevWorkspaceBuilder()
.withMetadata({
name,
namespace,
})
.withTemplate({
components: [
{
name: 'default',
attributes: { 'che.eclipse.org/default-plugin': true },
plugin: { uri: 'https://test.com/devfile.yaml' },
},
],
})
.build();

// No default plugins
const getDefaultPlugins = jest
.spyOn(ServerConfigApi, 'getDefaultPlugins')
.mockResolvedValueOnce([]);
const patchWorkspace = jest.spyOn(DwApi, 'patchWorkspace');
await client.onStart(testWorkspace, '');
expect(testWorkspace.spec.template.components!.length).toBe(0);
expect(getDefaultPlugins).toHaveBeenCalled();
expect(patchWorkspace).toHaveBeenCalled();
});

it('should not remove non default plugin uri when no default plugins exist', async () => {
const namespace = 'che';
const name = 'wksp-test';
const uri = 'https://test.com/devfile.yaml';
const testWorkspace = new DevWorkspaceBuilder()
.withMetadata({
name,
namespace,
})
.withTemplate({
components: [
{
name: 'my-plugin',
plugin: { uri },
},
],
})
.build();

// No default plugins
const getDefaultPlugins = jest
.spyOn(ServerConfigApi, 'getDefaultPlugins')
.mockResolvedValueOnce([]);
const patchWorkspace = jest.spyOn(DwApi, 'patchWorkspace');
await client.onStart(testWorkspace, '');
expect(testWorkspace.spec.template.components!.length).toBe(1);
expect(testWorkspace.spec.template.components![0].plugin!.uri!).toBe(uri);
expect(testWorkspace.spec.template.components![0].attributes).toBeUndefined();
expect(getDefaultPlugins).toHaveBeenCalled();
expect(patchWorkspace).toHaveBeenCalled();
});

it('should not remove non default plugin uri if attribute is false', async () => {
const namespace = 'che';
const name = 'wksp-test';
const uri = 'https://test.com/devfile.yaml';
const testWorkspace = new DevWorkspaceBuilder()
.withMetadata({
name,
namespace,
})
.withTemplate({
components: [
{
name: 'default',
attributes: { 'che.eclipse.org/default-plugin': false },
plugin: { uri: 'https://test.com/devfile.yaml' },
},
],
})
.build();

// No default plugins
const getDefaultPlugins = jest
.spyOn(ServerConfigApi, 'getDefaultPlugins')
.mockResolvedValueOnce([]);
const patchWorkspace = jest.spyOn(DwApi, 'patchWorkspace');
await client.onStart(testWorkspace, '');
expect(testWorkspace.spec.template.components!.length).toBe(1);
expect(testWorkspace.spec.template.components![0].plugin!.uri!).toBe(uri);
expect(getDefaultPlugins).toHaveBeenCalled();
expect(patchWorkspace).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { KeycloakSetupService } from '../../keycloak/setup';
import { delay } from '../../helpers/delay';
import * as DwApi from '../../dashboard-backend-client/devWorkspaceApi';
import * as DwtApi from '../../dashboard-backend-client/devWorkspaceTemplateApi';
import * as ServerConfigApi from '../../dashboard-backend-client/serverConfigApi';
import { WebsocketClient, SubscribeMessage } from '../../dashboard-backend-client/websocketClient';
import { EventEmitter } from 'events';
import { AppAlerts } from '../../alerts/appAlerts';
Expand All @@ -40,6 +41,7 @@ import {
} from '@devfile/api';
import { isEqual } from 'lodash';
import { fetchData } from '../../registry/devfiles';
import { DevWorkspaceDefaultPluginsHandler } from './DevWorkspaceDefaultPluginsHandler';

export interface IStatusUpdate {
status: string;
Expand Down Expand Up @@ -135,10 +137,13 @@ export class DevWorkspaceClient extends WorkspaceClient {
private readonly webSocketEventName: string;
private readonly _failingWebSockets: string[];
private readonly showAlert: (alert: AlertItem) => void;
private readonly defaultPluginsHandler: DevWorkspaceDefaultPluginsHandler;

constructor(
@inject(KeycloakSetupService) keycloakSetupService: KeycloakSetupService,
@inject(AppAlerts) appAlerts: AppAlerts,
@inject(DevWorkspaceDefaultPluginsHandler)
defaultPluginsHandler: DevWorkspaceDefaultPluginsHandler,
@multiInject(IDevWorkspaceEditorProcess) private editorProcesses: IDevWorkspaceEditorProcess[],
) {
super(keycloakSetupService);
Expand All @@ -150,6 +155,7 @@ export class DevWorkspaceClient extends WorkspaceClient {
this.webSocketEventEmitter = new EventEmitter();
this.webSocketEventName = 'websocketClose';
this._failingWebSockets = [];
this.defaultPluginsHandler = defaultPluginsHandler;

this.showAlert = (alert: AlertItem) => appAlerts.showAlert(alert);

Expand Down Expand Up @@ -469,6 +475,16 @@ export class DevWorkspaceClient extends WorkspaceClient {
}
}

/**
* Called when a DevWorkspace has started.
*
* @param workspace The DevWorkspace that was started
* @param editorId The editor id of the DevWorkspace that was started
*/
async onStart(workspace: devfileApi.DevWorkspace, editorId: string) {
await this.defaultPluginsHandler.handle(workspace, editorId);
}

/**
* Update a devworkspace.
* If the workspace you want to update has the DEVWORKSPACE_NEXT_START_ANNOTATION then
Expand Down
Loading

0 comments on commit cd82a31

Please sign in to comment.