From 09906cdbd5d7c16bd996bd0bf7f7e6a15fe30841 Mon Sep 17 00:00:00 2001 From: Vitaliy Gulyy Date: Thu, 3 Feb 2022 17:46:04 +0200 Subject: [PATCH] feat(plugins): allow to install che-plugins in devworkspace (#1267) Signed-off-by: Vitaliy Gulyy --- .../eclipse-che-theia-about/package.json | 3 +- .../eclipse-che-theia-plugin-ext/package.json | 4 +- .../src/browser/che-frontend-module.ts | 11 +- .../plugin/che-plugin-command-contribution.ts | 2 +- .../plugin/che-plugin-frontend-service.ts | 4 +- .../src/browser/plugin/che-plugin-manager.ts | 402 +++- .../plugin/che-plugin-service-client.ts | 40 +- .../browser/plugin/che-plugin-view-list.tsx | 82 +- .../src/browser/plugin/che-plugin-view.tsx | 72 +- .../plugin/plugin-filter.ts | 10 +- .../style/che-plugins-notification.css | 23 +- .../src/browser/style/che-plugins.css | 82 +- .../src/node/che-backend-module.ts | 25 +- ...s => ws-request-validator-contribution.ts} | 4 +- .../src/che-proposed.d.ts | 3 +- .../eclipse-che-theia-remote-api/package.json | 1 + .../browser/che-remote-api-frontend-module.ts | 9 + .../src/common/dashboard-service.ts | 4 +- .../src/common/devfile-service.ts | 7 +- .../src/common/http-service.ts | 7 +- .../src/common/plugin-service-impl.ts} | 226 +-- .../src/common/plugin-service.ts} | 69 +- .../src/node/che-dashboard-service-impl.ts | 6 +- ...e-remote-impl-che-server-backend-module.ts | 26 +- .../src/node/che-server-http-service-impl.ts | 11 +- .../node/che-server-plugin-service-impl.ts | 185 ++ .../package.json | 3 +- .../src/node/k8s-backend-module.ts | 27 +- .../src/node/k8s-dashboard-service-impl.ts | 10 +- .../src/node/k8s-devfile-service-impl.ts | 30 +- .../src/node/k8s-http-service-impl.ts | 21 +- .../src/node/k8s-plugin-service-impl.ts | 781 ++++++++ .../tests/_data/devworkspace-template.json | 143 ++ .../node/k8s-plugin-service-impl.spec.ts | 1722 +++++++++++++++++ .../eclipse-che-theia-workspace/package.json | 3 +- 35 files changed, 3621 insertions(+), 437 deletions(-) rename extensions/eclipse-che-theia-plugin-ext/src/{common => browser}/plugin/plugin-filter.ts (86%) rename extensions/eclipse-che-theia-plugin-ext/src/node/{plugin-service.ts => ws-request-validator-contribution.ts} (92%) rename extensions/{eclipse-che-theia-plugin-ext/src/node/che-plugin-service.ts => eclipse-che-theia-remote-api/src/common/plugin-service-impl.ts} (56%) rename extensions/{eclipse-che-theia-plugin-ext/src/common/che-plugin-protocol.ts => eclipse-che-theia-remote-api/src/common/plugin-service.ts} (67%) create mode 100644 extensions/eclipse-che-theia-remote-impl-che-server/src/node/che-server-plugin-service-impl.ts create mode 100644 extensions/eclipse-che-theia-remote-impl-k8s/src/node/k8s-plugin-service-impl.ts create mode 100644 extensions/eclipse-che-theia-remote-impl-k8s/tests/_data/devworkspace-template.json create mode 100644 extensions/eclipse-che-theia-remote-impl-k8s/tests/node/k8s-plugin-service-impl.spec.ts diff --git a/extensions/eclipse-che-theia-about/package.json b/extensions/eclipse-che-theia-about/package.json index 12a1a4b8e..8e5e1953e 100644 --- a/extensions/eclipse-che-theia-about/package.json +++ b/extensions/eclipse-che-theia-about/package.json @@ -9,7 +9,8 @@ "inversify": "^5.0.1", "@theia/core": "next", "@theia/mini-browser": "next", - "@eclipse-che/theia-plugin-ext": "^0.0.1" + "@eclipse-che/theia-plugin-ext": "^0.0.1", + "react": "^16.8.0" }, "devDependencies": { "ts-jest": "27.0.7", diff --git a/extensions/eclipse-che-theia-plugin-ext/package.json b/extensions/eclipse-che-theia-plugin-ext/package.json index c638130e5..d14cb3db2 100644 --- a/extensions/eclipse-che-theia-plugin-ext/package.json +++ b/extensions/eclipse-che-theia-plugin-ext/package.json @@ -37,7 +37,9 @@ "drivelist": "9.0.2", "@eclipse-che/theia-remote-api": "^0.0.1", "@eclipse-che/workspace-telemetry-client": "latest", - "mime": "2.5.2" + "mime": "2.5.2", + "react": "^16.8.0", + "@phosphor/messaging": "1" }, "devDependencies": { "clean-webpack-plugin": "^3.0.0", diff --git a/extensions/eclipse-che-theia-plugin-ext/src/browser/che-frontend-module.ts b/extensions/eclipse-che-theia-plugin-ext/src/browser/che-frontend-module.ts index 7d91a58e3..4c7175da4 100644 --- a/extensions/eclipse-che-theia-plugin-ext/src/browser/che-frontend-module.ts +++ b/extensions/eclipse-che-theia-plugin-ext/src/browser/che-frontend-module.ts @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (c) 2018-2020 Red Hat, Inc. + * Copyright (c) 2018-2022 Red Hat, Inc. * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -21,7 +21,6 @@ import { CheTaskClient, CheTaskService, } from '../common/che-protocol'; -import { CHE_PLUGIN_SERVICE_PATH, ChePluginService, ChePluginServiceClient } from '../common/che-plugin-protocol'; import { CheSideCarContentReaderRegistryImpl, CheSideCarResourceResolver } from './che-sidecar-resource'; import { CommandContribution, ResourceResolver } from '@theia/core/lib/common'; import { ContainerModule, interfaces } from 'inversify'; @@ -35,6 +34,7 @@ import { ChePluginFrontentService } from './plugin/che-plugin-frontend-service'; import { ChePluginHandleRegistry } from './che-plugin-handle-registry'; import { ChePluginManager } from './plugin/che-plugin-manager'; import { ChePluginMenu } from './plugin/che-plugin-menu'; +import { ChePluginServiceClient } from '@eclipse-che/theia-remote-api/lib/common/plugin-service'; import { ChePluginServiceClientImpl } from './plugin/che-plugin-service-client'; import { ChePluginView } from './plugin/che-plugin-view'; import { ChePluginViewContribution } from './plugin/che-plugin-view-contribution'; @@ -72,13 +72,6 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(ChePluginServiceClientImpl).toSelf().inSingletonScope(); bind(ChePluginServiceClient).toService(ChePluginServiceClientImpl); - bind(ChePluginService) - .toDynamicValue(ctx => { - const provider = ctx.container.get(WebSocketConnectionProvider); - const client: ChePluginServiceClient = ctx.container.get(ChePluginServiceClient); - return provider.createProxy(CHE_PLUGIN_SERVICE_PATH, client); - }) - .inSingletonScope(); rebind(WebviewEnvironment).to(CheWebviewEnvironment).inSingletonScope(); diff --git a/extensions/eclipse-che-theia-plugin-ext/src/browser/plugin/che-plugin-command-contribution.ts b/extensions/eclipse-che-theia-plugin-ext/src/browser/plugin/che-plugin-command-contribution.ts index 97af84a77..926da9b5a 100644 --- a/extensions/eclipse-che-theia-plugin-ext/src/browser/plugin/che-plugin-command-contribution.ts +++ b/extensions/eclipse-che-theia-plugin-ext/src/browser/plugin/che-plugin-command-contribution.ts @@ -12,7 +12,7 @@ import { Command, CommandContribution, CommandRegistry, MessageService } from '@ import { inject, injectable } from 'inversify'; import { ChePluginManager } from './che-plugin-manager'; -import { ChePluginRegistry } from '../../common/che-plugin-protocol'; +import { ChePluginRegistry } from '@eclipse-che/theia-remote-api/lib/common/plugin-service'; import { QuickInputService } from '@theia/core/lib/browser'; function cmd(id: string, label: string): Command { diff --git a/extensions/eclipse-che-theia-plugin-ext/src/browser/plugin/che-plugin-frontend-service.ts b/extensions/eclipse-che-theia-plugin-ext/src/browser/plugin/che-plugin-frontend-service.ts index e4ce4dfff..3a7993232 100644 --- a/extensions/eclipse-che-theia-plugin-ext/src/browser/plugin/che-plugin-frontend-service.ts +++ b/extensions/eclipse-che-theia-plugin-ext/src/browser/plugin/che-plugin-frontend-service.ts @@ -11,8 +11,8 @@ import { DeployedPlugin, HostedPluginServer, PluginMetadata } from '@theia/plugin-ext/lib/common/plugin-protocol'; import { inject, injectable } from 'inversify'; -import { ChePluginMetadata } from '../../common/che-plugin-protocol'; -import { PluginFilter } from '../../common/plugin/plugin-filter'; +import { ChePluginMetadata } from '@eclipse-che/theia-remote-api/lib/common/plugin-service'; +import { PluginFilter } from './plugin-filter'; @injectable() export class ChePluginFrontentService { diff --git a/extensions/eclipse-che-theia-plugin-ext/src/browser/plugin/che-plugin-manager.ts b/extensions/eclipse-che-theia-plugin-ext/src/browser/plugin/che-plugin-manager.ts index a4a42e9af..cd32f2815 100644 --- a/extensions/eclipse-che-theia-plugin-ext/src/browser/plugin/che-plugin-manager.ts +++ b/extensions/eclipse-che-theia-plugin-ext/src/browser/plugin/che-plugin-manager.ts @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (c) 2018-2020 Red Hat, Inc. + * Copyright (c) 2018-2022 Red Hat, Inc. * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -9,29 +9,51 @@ ***********************************************************************/ import { - ChePlugin, + Changes, ChePluginMetadata, ChePluginRegistries, ChePluginRegistry, ChePluginService, -} from '../../common/che-plugin-protocol'; +} from '@eclipse-che/theia-remote-api/lib/common/plugin-service'; import { ConfirmDialog, OpenerService } from '@theia/core/lib/browser'; -import { Emitter, Event, MessageService } from '@theia/core/lib/common'; +import { ConnectionStatus, ConnectionStatusService } from '@theia/core/lib/browser/connection-status-service'; +import { Emitter, Event, MessageService, MessageType, ProgressMessage, ProgressService } from '@theia/core/lib/common'; import { PreferenceChange, PreferenceScope, PreferenceService } from '@theia/core/lib/browser/preferences'; import { inject, injectable, postConstruct } from 'inversify'; import { ChePluginFrontentService } from './che-plugin-frontend-service'; import { ChePluginPreferences } from './che-plugin-preferences'; import { ChePluginServiceClientImpl } from './che-plugin-service-client'; +import { DashboardService } from '@eclipse-che/theia-remote-api/lib/common/dashboard-service'; import { DevfileService } from '@eclipse-che/theia-remote-api/lib/common/devfile-service'; import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; -import { PluginFilter } from '../../common/plugin/plugin-filter'; +import { PluginFilter } from './plugin-filter'; import { PluginServer } from '@theia/plugin-ext/lib/common/plugin-protocol'; import URI from '@theia/core/lib/common/uri'; import { WorkspaceService } from '@eclipse-che/theia-remote-api/lib/common/workspace-service'; import debounce = require('lodash.debounce'); +export type ChePluginStatus = + | 'not_installed' + | 'installed' + | 'installing' + | 'removing' + | 'to_be_installed' + | 'to_be_removed' + | 'cancelling_installation' + | 'cancelling_removal'; + +export interface ChePlugin { + publisher: string; + name: string; + version: string; + status: ChePluginStatus; + versionList: { + [version: string]: ChePluginMetadata; + }; +} + @injectable() export class ChePluginManager { /** @@ -45,20 +67,35 @@ export class ChePluginManager { private registryList: ChePluginRegistry[]; /** - * List of installed plugins. + * List of installed plugins in format ${publisher}/${name}/${version} * Initialized by plugins received from workspace config. */ private installedPlugins: string[]; + /** + * List of plugins in format ${publisher}/${name}/${version} + * that will be installed and removed. + */ + private changes: Changes = { toInstall: [], toRemove: [] }; + @inject(ChePluginService) protected readonly chePluginService: ChePluginService; + @inject(DashboardService) + protected readonly dashboardService: DashboardService; + @inject(PluginServer) protected readonly pluginServer: PluginServer; @inject(MessageService) protected readonly messageService: MessageService; + @inject(ProgressService) + protected readonly progressService: ProgressService; + + @inject(ConnectionStatusService) + protected readonly connectionStatusService: ConnectionStatusService; + @inject(EnvVariablesServer) protected readonly envVariablesServer: EnvVariablesServer; @@ -83,14 +120,32 @@ export class ChePluginManager { @inject(DevfileService) protected readonly devfileService: DevfileService; + private pageReloadURL: string | undefined; + + protected onInitialized: () => void; + protected onInitializationFailed: () => void; + + protected init: Promise = new Promise((resolve, reject) => { + this.onInitialized = resolve; + this.onInitializationFailed = reject; + }); + @postConstruct() async onStart(): Promise { - await this.initDefaults(); + try { + await this.initDefaults(); + this.onInitialized(); + } catch (error) { + this.onInitializationFailed(); + } + const fireChanged = debounce(() => this.pluginRegistryListChangedEvent.fire(), 5000); + this.preferenceService.onPreferenceChanged(async (event: PreferenceChange) => { if (event.preferenceName !== 'chePlugins.repositories') { return; } + const oldPrefs = event.oldValue; if (oldPrefs) { for (const repoName of Object.keys(oldPrefs)) { @@ -100,6 +155,7 @@ export class ChePluginManager { } } } + const newPrefs = event.newValue; if (newPrefs) { for (const repoName of Object.keys(newPrefs)) { @@ -109,6 +165,7 @@ export class ChePluginManager { // notify that plugin registry list has been changed fireChanged(); }); + this.chePluginServiceClient.onInvalidRegistryFound(async registry => { const result = await this.messageService.warn( `Invalid plugin registry URL: "${registry.internalURI}" is detected`, @@ -120,6 +177,51 @@ export class ChePluginManager { this.openerService.getOpener(uri).then(opener => opener.open(uri)); } }); + + this.chePluginServiceClient.onAskToInstallDependencies(async ask => { + const confirm = new ConfirmDialog({ + title: 'Required Dependencies', + msg: `Plugin requires to install ${ask.dependencies.toString()}`, + ok: 'Install', + }); + + if (await confirm.open()) { + // remove necessary dependencies from toRemove list if present + ask.dependencies.forEach(dependency => { + if (this.changes.toRemove.find(value => value === dependency)) { + this.changes.toRemove = this.changes.toRemove.filter(value => value !== dependency); + } + }); + + // add dependencies toInstall list + ask.dependencies.forEach(dependency => { + if (!this.changes.toInstall.find(value => value === dependency)) { + this.changes.toInstall.push(dependency); + } + }); + + // notify backend + ask.confirm(); + } else { + ask.deny(); + } + }); + + this.connectionStatusService.onStatusChange(status => { + if (status === ConnectionStatus.OFFLINE) { + this.handleOffline(); + } + }); + } + + /******************************************************************************** + * Using to notify plugins view, that the plugin service is appying changes + ********************************************************************************/ + + protected readonly applyingChangesEvent = new Emitter(); + + get onApplyingChanges(): Event { + return this.applyingChangesEvent.event; } /******************************************************************************** @@ -166,22 +268,42 @@ export class ChePluginManager { } } + private initialized = false; + + private deferredInstallation = false; + private async initDefaults(): Promise { - if (!this.defaultRegistry) { - this.defaultRegistry = await this.chePluginService.getDefaultRegistry(); + if (this.initialized) { + return; } - if (!this.registryList) { - this.registryList = [this.defaultRegistry]; - await this.restoreRegistryList(); - } + this.defaultRegistry = await this.chePluginService.getDefaultRegistry(); + + this.registryList = [this.defaultRegistry]; + await this.restoreRegistryList(); + + // Get list of installed plugins + this.installedPlugins = await this.chePluginService.getInstalledPlugins(); - if (!this.installedPlugins) { - // Get list of plugins from workspace config - this.installedPlugins = await this.chePluginService.getWorkspacePlugins(); + // check for deferred installation + this.deferredInstallation = await this.chePluginService.deferredInstallation(); + + // for deferred installation get list of changes + const unpersistedChanges = await this.chePluginService.getUnpersistedChanges(); + if (unpersistedChanges) { + this.changes = { + toInstall: [...unpersistedChanges.toInstall], + toRemove: [...unpersistedChanges.toRemove], + }; + } else { + this.changes = { toInstall: [], toRemove: [] }; } } + isDeferredInstallation(): boolean { + return this.deferredInstallation; + } + addRegistry(registry: ChePluginRegistry): void { this.registryList.push(registry); @@ -211,7 +333,7 @@ export class ChePluginManager { * Udates the Plugin cache */ async updateCache(): Promise { - await this.initDefaults(); + await this.init; /** * Need to prepare this object to pass the plugins array through RPC. @@ -247,7 +369,7 @@ export class ChePluginManager { * Returns plugin list from active registry */ async getPlugins(filter: string): Promise { - await this.initDefaults(); + await this.init; if (PluginFilter.hasType(filter, '@builtin')) { try { @@ -258,12 +380,8 @@ export class ChePluginManager { } } - // Filter plugins if user requested the list of installed plugins - if (PluginFilter.hasType(filter, '@installed')) { - return await this.getInstalledPlugins(filter); - } - - return await this.getAllPlugins(filter); + const installedPluginsOnly = PluginFilter.hasType(filter, '@installed'); + return this.getAllPlugins(filter, installedPluginsOnly); } private async getBuiltInPlugins(filter: string): Promise { @@ -274,64 +392,50 @@ export class ChePluginManager { /** * Returns the list of available plugins for the active plugin registry. */ - private async getAllPlugins(filter: string): Promise { + private async getAllPlugins(filter: string, listInstalledOnly?: boolean): Promise { // get list of all plugins - const rawPlugins = await this.chePluginService.getPlugins(filter); + const rawPlugins = await this.chePluginService.getPlugins(); + const filteredPlugins = PluginFilter.filterPlugins(rawPlugins, filter); // group the plugins - const grouppedPlugins = this.groupPlugins(rawPlugins); + const grouppedPlugins = this.groupPlugins(filteredPlugins); // prepare list of installed plugins without versions and repository URI const installedPluginsInfo = this.getInstalledPluginsInfo(); - // update `installed` field for all the plugin + const installedPlugins: ChePlugin[] = []; + // if the plugin is installed, we need to set the proper version grouppedPlugins.forEach(plugin => { - const publisherName = `${plugin.publisher}/${plugin.name}`; - installedPluginsInfo.forEach(info => { - if (info.publisherName === publisherName) { + // check whether plugin is installed + installedPluginsInfo.forEach(value => { + if (plugin.publisher === value.publisher && plugin.name === value.name) { // set plugin is installed - plugin.installed = true; + plugin.status = 'installed'; // set intalled version - plugin.version = info.version; + plugin.version = value.version; + installedPlugins.push(plugin); } }); - }); - return grouppedPlugins; - } - - /** - * Returns the list of installed plugins - */ - private async getInstalledPlugins(filter: string): Promise { - const rawPlugins: ChePluginMetadata[] = await this.chePluginService.getPlugins(filter); - - // group the plugins - const grouppedPlugins = this.groupPlugins(rawPlugins); - - // prepare list of installed plugins without versions and repository URI - const installedPluginsInfo = this.getInstalledPluginsInfo(); - - const installedPlugins: ChePlugin[] = []; - - // update `installed` field for all the plugin - // if the plugin is installed, we ned to set the proper version - grouppedPlugins.forEach(plugin => { - const publisherName = `${plugin.publisher}/${plugin.name}`; - installedPluginsInfo.forEach(info => { - if (info.publisherName === publisherName) { - // set plugin is installed - plugin.installed = true; - // set intalled version - plugin.version = info.version; + // check whether plugin will be installed + this.changes.toInstall.forEach(value => { + const parts = value.split('/'); + if (plugin.publisher === parts[0] && plugin.name === parts[1]) { + plugin.status = 'to_be_installed'; + } + }); - installedPlugins.push(plugin); + // check whether plugin will be removed + this.changes.toRemove.forEach(value => { + const parts = value.split('/'); + if (plugin.publisher === parts[0] && plugin.name === parts[1]) { + plugin.status = 'to_be_removed'; } }); }); - return installedPlugins; + return listInstalledOnly ? installedPlugins : grouppedPlugins; } /** @@ -349,7 +453,7 @@ export class ChePluginManager { * must be replaced on * eclipse-che/tree-view-sample-plugin */ - private getInstalledPluginsInfo(): { publisherName: string; version: string }[] { + private getInstalledPluginsInfo(): { publisher: string; name: string; version: string }[] { // prepare the list of registries // we need to remove the registry URI from the start of the plugin const registries: string[] = []; @@ -368,7 +472,8 @@ export class ChePluginManager { registries.push(uri); }); - const plugins: { publisherName: string; version: string }[] = []; + const plugins: { publisher: string; name: string; version: string }[] = []; + this.installedPlugins.forEach(plugin => { if (plugin.endsWith('/meta.yaml')) { // it's non default registry @@ -388,8 +493,11 @@ export class ChePluginManager { } }); + const parts = plugin.split('/'); + plugins.push({ - publisherName: plugin, + publisher: parts[0], + name: parts[1], version, }); }); @@ -411,7 +519,7 @@ export class ChePluginManager { publisher: plugin.publisher, name: plugin.name, version: plugin.version, - installed: false, + status: 'not_installed', versionList: {}, }; @@ -438,24 +546,31 @@ export class ChePluginManager { */ async install(plugin: ChePlugin): Promise { const metadata = plugin.versionList[plugin.version]; + const pluginId = `${plugin.publisher}/${plugin.name}/${plugin.version}`; try { // add the plugin to workspace configuration - await this.chePluginService.addPlugin(metadata.key); - this.messageService.info( - `Plugin '${metadata.publisher}/${metadata.name}/${metadata.version}' has been successfully installed` - ); + const installed = await this.chePluginService.installPlugin(metadata.key); + if (!installed) { + return false; + } - // add the plugin to the list of workspace plugins - this.installedPlugins.push(metadata.key); + if (this.deferredInstallation) { + if (!this.changes.toInstall.find(value => value === pluginId)) { + this.changes.toInstall.push(pluginId); + } + } else { + this.messageService.info(`Plugin '${pluginId}' has been successfully installed`); + // add the plugin to the list of workspace plugins + this.installedPlugins.push(metadata.key); + } // notify that workspace configuration has been changed this.notifyWorkspaceConfigurationChanged(); + return true; } catch (error) { - this.messageService.error( - `Unable to install plugin '${metadata.publisher}/${metadata.name}/${metadata.version}'. ${error.message}` - ); + this.messageService.error(`Unable to install plugin '${pluginId}'. ${error.message}`); return false; } } @@ -465,23 +580,64 @@ export class ChePluginManager { */ async remove(plugin: ChePlugin): Promise { const metadata = plugin.versionList[plugin.version]; + const pluginId = `${plugin.publisher}/${plugin.name}/${plugin.version}`; try { // remove the plugin from workspace configuration - const key = `${metadata.publisher}/${metadata.name}/${metadata.version}`; - await this.chePluginService.removePlugin(key); - this.messageService.info(`Plugin '${key}' has been successfully removed`); + await this.chePluginService.removePlugin(pluginId); - // remove the plugin from the list of workspace plugins - this.installedPlugins = this.installedPlugins.filter(p => p !== metadata.key); + if (this.deferredInstallation) { + if (!this.changes.toRemove.find(value => value === pluginId)) { + this.changes.toRemove.push(pluginId); + } + } else { + this.messageService.info(`Plugin '${pluginId}' has been successfully removed`); + + // remove the plugin from the list of workspace plugins + this.installedPlugins = this.installedPlugins.filter(p => p !== metadata.key); + } // notify that workspace configuration has been changed this.notifyWorkspaceConfigurationChanged(); return true; } catch (error) { - this.messageService.error( - `Unable to remove plugin '${metadata.publisher}/${metadata.name}/${metadata.version}'. ${error.message}` - ); + this.messageService.warn(error.message ? error.message : error); + return false; + } + } + + async undoInstall(plugin: ChePlugin): Promise { + const pluginId = `${plugin.publisher}/${plugin.name}/${plugin.version}`; + + try { + // remove the plugin from workspace configuration + await this.chePluginService.removePlugin(pluginId); + + this.changes.toInstall = this.changes.toInstall.filter(value => value !== pluginId); + + // notify that workspace configuration has been changed + this.notifyWorkspaceConfigurationChanged(); + + return true; + } catch (error) { + this.messageService.warn(error.message ? error.message : error); + return false; + } + } + + async undoRemove(plugin: ChePlugin): Promise { + const pluginId = `${plugin.publisher}/${plugin.name}/${plugin.version}`; + + try { + // call installPlugin to revert plugin removal + await this.chePluginService.installPlugin(pluginId); + this.changes.toRemove = this.changes.toRemove.filter(value => value !== pluginId); + + // notify that workspace configuration has been changed + this.notifyWorkspaceConfigurationChanged(); + return true; + } catch (error) { + this.messageService.error(`Unable to remove plugin '${pluginId}'. ${error.message}`); return false; } } @@ -572,7 +728,7 @@ export class ChePluginManager { * If yes, IDE can send request to the dashboard to restart the workspace. */ restartEnabled(): boolean { - return window.parent !== window; + return window.parent !== window || this.deferredInstallation; } async restartWorkspace(): Promise { @@ -593,4 +749,80 @@ export class ChePluginManager { window.parent.postMessage(`restart-workspace:${cheWorkspaceID}:${cheMachineTokenValue}`, '*'); } } + + async finalizeInstallation(): Promise { + this.pageReloadURL = await this.dashboardService.getEditorUrl(); + + try { + if (this.pageReloadURL) { + const confirm = new ConfirmDialog({ + title: 'Restart Workspace', + msg: 'After applying changes your workspace will be restarted', + ok: 'Apply and Restart', + }); + + if (await confirm.open()) { + await this.appyChanges(); + } + } else { + await this.chePluginService.persist(); + } + } catch (error) { + this.messageService.error(error.message); + } + } + + private async appyChanges(): Promise { + this.applyingChangesEvent.fire(true); + + const message: ProgressMessage = { + type: MessageType.Progress, + text: 'Applying changes...', + options: { + location: 'notification', + }, + }; + + const progress = await this.progressService.showProgress(message); + + try { + await this.chePluginService.persist(); + progress.report({ + work: { + total: 1, + done: 1, + }, + }); + + // in case ConnectionStatusService will not catch switching to offline mode, + // this timer will guarantee the page will be reloaded + await new Promise(resolve => setTimeout(resolve, 5000)); + await this.handleOffline(); + } catch (e) { + progress.cancel(); + + this.messageService.error(e.message ? e.message : e); + + const confirm = new ConfirmDialog({ + title: 'Try again', + msg: 'An error occured while applying changes. Would you like to retry?', + ok: 'Retry', + }); + + if (await confirm.open()) { + await this.appyChanges(); + } else { + this.pageReloadURL = undefined; + this.applyingChangesEvent.fire(false); + } + } + } + + async handleOffline(): Promise { + if (this.pageReloadURL) { + // timeout here lets Theia to update the UI after switching to offline mode + await new Promise(resolve => setTimeout(resolve, 500)); + window.location.replace(this.pageReloadURL); + } + } } diff --git a/extensions/eclipse-che-theia-plugin-ext/src/browser/plugin/che-plugin-service-client.ts b/extensions/eclipse-che-theia-plugin-ext/src/browser/plugin/che-plugin-service-client.ts index 32b5b1836..cedbb057b 100644 --- a/extensions/eclipse-che-theia-plugin-ext/src/browser/plugin/che-plugin-service-client.ts +++ b/extensions/eclipse-che-theia-plugin-ext/src/browser/plugin/che-plugin-service-client.ts @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (c) 2018-2020 Red Hat, Inc. + * Copyright (c) 2018-2022 Red Hat, Inc. * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -8,7 +8,11 @@ * SPDX-License-Identifier: EPL-2.0 ***********************************************************************/ -import { ChePluginRegistry, ChePluginServiceClient } from '../../common/che-plugin-protocol'; +import { + ChePluginRegistry, + ChePluginServiceClient, + PluginDependencies, +} from '@eclipse-che/theia-remote-api/lib/common/plugin-service'; import { Emitter, Event } from '@theia/core/lib/common'; import { injectable } from 'inversify'; @@ -69,8 +73,7 @@ export class ChePluginServiceClientImpl implements ChePluginServiceClient { } /******************************************************************************** - * Error handling - * Will be imlemented soon + * Handles errors when the backend service fails to read plugin registry ********************************************************************************/ async invalidRegistryFound(registry: ChePluginRegistry): Promise { @@ -81,4 +84,33 @@ export class ChePluginServiceClientImpl implements ChePluginServiceClient { async invalidPluginFound(pluginYaml: string): Promise { console.log('Unable to read plugin meta.yaml', pluginYaml); } + + /******************************************************************************** + * Handles request from plugin service on installing plugin dependencies + ********************************************************************************/ + + protected readonly askToInstallDependenciesEvent = new Emitter(); + + get onAskToInstallDependencies(): Event { + return this.askToInstallDependenciesEvent.event; + } + + async askToInstallDependencies(dependencies: PluginDependencies): Promise { + return new Promise(resolve => { + const confirmation = new AskToInstallDependencies(dependencies.plugins, resolve); + this.askToInstallDependenciesEvent.fire(confirmation); + }); + } +} + +export class AskToInstallDependencies { + constructor(public dependencies: string[], private resolve: (value: boolean) => void) {} + + confirm(): void { + this.resolve(true); + } + + deny(): void { + this.resolve(false); + } } diff --git a/extensions/eclipse-che-theia-plugin-ext/src/browser/plugin/che-plugin-view-list.tsx b/extensions/eclipse-che-theia-plugin-ext/src/browser/plugin/che-plugin-view-list.tsx index df69e867f..0566cddc4 100644 --- a/extensions/eclipse-che-theia-plugin-ext/src/browser/plugin/che-plugin-view-list.tsx +++ b/extensions/eclipse-che-theia-plugin-ext/src/browser/plugin/che-plugin-view-list.tsx @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (c) 2018-2020 Red Hat, Inc. + * Copyright (c) 2018-2022 Red Hat, Inc. * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -9,8 +9,8 @@ ***********************************************************************/ import * as React from 'react'; -import { ChePluginManager } from './che-plugin-manager'; -import { ChePlugin, ChePluginMetadata } from '../../common/che-plugin-protocol'; +import { ChePlugin, ChePluginManager, ChePluginStatus } from './che-plugin-manager'; +import { ChePluginMetadata } from '@eclipse-che/theia-remote-api/lib/common/plugin-service'; interface ListProps { pluginManager: ChePluginManager; @@ -21,8 +21,6 @@ interface ListProps { interface ListState { } -export type PluginStatus = 'not_installed' | 'installed' | 'installing' | 'removing'; - export class ChePluginViewList extends React.Component { constructor(props: ListProps) { @@ -52,7 +50,7 @@ interface ListItemProps { } interface ListItemState { - pluginStatus: PluginStatus; + pluginStatus: ChePluginStatus; iconFailed: boolean; } @@ -60,9 +58,7 @@ export class ChePluginListItem extends React.Component{text}]; } + componentDidUpdate(): void { + const plugin = this.props.pluginItem; + + // align state with plugin.status + if (plugin.status !== this.state.pluginStatus) { + this.setState({ + pluginStatus: plugin.status, + iconFailed: this.state.iconFailed + }); + } + } + render(): React.ReactNode { const plugin = this.props.pluginItem; const metadata = plugin.versionList[plugin.version]; @@ -191,7 +199,6 @@ export class ChePluginListItem extends React.Component
{this.renderIcon(metadata)} @@ -271,18 +278,28 @@ export class ChePluginListItem extends React.ComponentInstalled
; + return
Installed
; case 'installing': return
Installing...
; case 'removing': return
Removing...
; - } + case 'to_be_installed': + return
To be Installed
; + case 'to_be_removed': + return
To be Removed
; + case 'cancelling_installation': + return
Cancelling...
; + case 'cancelling_removal': + return
Cancelling...
; + } // 'not_installed' - return
Install
; + return
Install
; } - protected setStatus(status: PluginStatus): void { + protected setStatus(status: ChePluginStatus): void { + this.props.pluginItem.status = status; + this.setState({ pluginStatus: status, iconFailed: this.state.iconFailed @@ -294,23 +311,46 @@ export class ChePluginListItem extends React.Component { + const previousStatus = this.state.pluginStatus; + this.setStatus('cancelling_installation'); + + const cancelled = await this.props.pluginManager.undoInstall(this.props.pluginItem); + this.setStatus(cancelled ? 'not_installed' : previousStatus); + }; + protected removePlugin = async () => { const previousStatus = this.state.pluginStatus; this.setStatus('removing'); const removed = await this.props.pluginManager.remove(this.props.pluginItem); - if (removed) { - this.setStatus('not_installed'); - } else { + + if (!removed) { this.setStatus(previousStatus); + return; } + + if (this.props.pluginManager.isDeferredInstallation()) { + this.setStatus('to_be_removed'); + } else { + this.setStatus('not_installed'); + } + }; + + protected undoRemove = async () => { + const previousStatus = this.state.pluginStatus; + this.setStatus('cancelling_removal'); + + const cancelled = await this.props.pluginManager.undoRemove(this.props.pluginItem); + this.setStatus(cancelled ? 'installed' : previousStatus); }; protected versionChanged = async (event: React.ChangeEvent) => { @@ -325,7 +365,7 @@ export class ChePluginListItem extends React.Component this.onWorkspaceConfigurationChanged())); this.toDispose.push(this.chePluginManager.onPluginRegistryListChanged(() => this.updateCache())); + this.toDispose.push(this.chePluginManager.onApplyingChanges(lock => this.onApplyingChanges(lock))); this.toDispose.push(this.chePluginMenu.onChangeFilter(filter => this.onChangeFilter(filter))); this.toDispose.push(this.chePluginMenu.onRefreshPluginList(() => this.updateCache())); this.toDispose.push(this.chePluginServiceClient.onPluginCacheSizeChanged(plugins => this.onPluginCacheSizeChanged(plugins))); @@ -91,6 +91,11 @@ export class ChePluginView extends PluginWidget { this.filter(); } + protected async onApplyingChanges(lock: boolean): Promise { + this.status = lock ? 'loading' : 'filtering'; + this.update(); + } + protected async onPluginCacheSizeChanged(plugins: number): Promise { this.pluginCacheSize = plugins; this.update(); @@ -265,32 +270,57 @@ export class ChePluginView extends PluginWidget { const restartEnabled = this.chePluginManager.restartEnabled(); - let notificationStyle = this.hidingRestartWorkspaceNotification ? 'notification hiding' : 'notification'; - if (restartEnabled) { - notificationStyle += ' notification-button-panel'; + let notificationInnerStyle = this.hidingRestartWorkspaceNotification ? 'notification hiding' : 'notification'; + if (restartEnabled && this.status !== 'loading') { + notificationInnerStyle += ' notification-button-panel'; } - const notification = restartEnabled ? -
-
-
Click here to apply changes and restart your workspace
-
- : + let notification; + if (restartEnabled) { + if (this.chePluginManager.isDeferredInstallation()) { + if (this.status === 'loading') { + notification = +
+
+
Applying changes...
+
+ } else { + notification = +
+
+
Apply changes and restart your workspace
+
+ } + } else { + notification = +
+
+
Click here to apply changes and restart your workspace
+
+ } + } else { + notification =
Use dashboard to apply changes and restart your workspace
; + } + + const control = this.chePluginManager.isDeferredInstallation() ? undefined : +
+
+ +
+
; return
-
+
{notification} -
-
- -
-
+ {control}
; } @@ -342,6 +372,10 @@ export class ChePluginView extends PluginWidget { await this.chePluginManager.restartWorkspace(); }; + protected onApplyChangesClicked = async () => { + await this.chePluginManager.finalizeInstallation(); + } + protected hideNotification = async () => { this.hidingRestartWorkspaceNotification = true; this.update(); diff --git a/extensions/eclipse-che-theia-plugin-ext/src/common/plugin/plugin-filter.ts b/extensions/eclipse-che-theia-plugin-ext/src/browser/plugin/plugin-filter.ts similarity index 86% rename from extensions/eclipse-che-theia-plugin-ext/src/common/plugin/plugin-filter.ts rename to extensions/eclipse-che-theia-plugin-ext/src/browser/plugin/plugin-filter.ts index da32d4b97..d497fe13e 100644 --- a/extensions/eclipse-che-theia-plugin-ext/src/common/plugin/plugin-filter.ts +++ b/extensions/eclipse-che-theia-plugin-ext/src/browser/plugin/plugin-filter.ts @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (c) 2019-2020 Red Hat, Inc. + * Copyright (c) 2019-2022 Red Hat, Inc. * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -8,7 +8,7 @@ * SPDX-License-Identifier: EPL-2.0 ***********************************************************************/ -import { ChePluginMetadata } from '../che-plugin-protocol'; +import { ChePluginMetadata } from '@eclipse-che/theia-remote-api/lib/common/plugin-service'; export namespace PluginFilter { // @installed @@ -49,10 +49,8 @@ export namespace PluginFilter { const filters = filter.split(' '); filters.forEach(f => { - if (f) { - if (!f.startsWith('@')) { - filteredPlugins = PluginFilter.filterByText(filteredPlugins, f); - } + if (f && !f.startsWith('@')) { + filteredPlugins = PluginFilter.filterByText(filteredPlugins, f); } }); diff --git a/extensions/eclipse-che-theia-plugin-ext/src/browser/style/che-plugins-notification.css b/extensions/eclipse-che-theia-plugin-ext/src/browser/style/che-plugins-notification.css index e5d4be90b..bf6b95899 100644 --- a/extensions/eclipse-che-theia-plugin-ext/src/browser/style/che-plugins-notification.css +++ b/extensions/eclipse-che-theia-plugin-ext/src/browser/style/che-plugins-notification.css @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (c) 2018-2020 Red Hat, Inc. + * Copyright (c) 2018-2022 Red Hat, Inc. * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -26,17 +26,18 @@ margin-left: 1px; overflow: hidden; transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, opacity 500ms ease-in-out 0ms; - background-color: var(--theia-successBackground); - color: #FFFFFF; + background-color: var(--theia-button-background); + color: var(--theia-button-foreground); } .che-plugins-notification .notification-button-panel:hover { - background-color: var(--theia-button-hover-background); + background-color: var(--theia-button-hoverBackground); } -.che-plugins-notification .notification-button-panel:active { - box-shadow: 0px 0px 2px 0px var(--theia-button-background); +.che-plugins-notification .notification-button-panel:focus { + box-shadow: 0px 0px 1px 1px var(--theia-focusBorder); outline: none; + background-color: var(--theia-button-hoverBackground); } .che-plugins-notification .notification.hiding { @@ -44,7 +45,8 @@ } .che-plugins-notification .notification-message, -.che-plugins-notification .notification-button { +.che-plugins-notification .notification-button, +.che-plugins-notification .notification-persist-button { position: absolute; left: 0px; top: 9px; @@ -54,7 +56,12 @@ overflow: hidden; } -.che-plugins-notification .notification-button { +.che-plugins-notification .notification-persist-button { + right: 1px; +} + +.che-plugins-notification .notification-button, +.che-plugins-notification .notification-persist-button { cursor: pointer; } diff --git a/extensions/eclipse-che-theia-plugin-ext/src/browser/style/che-plugins.css b/extensions/eclipse-che-theia-plugin-ext/src/browser/style/che-plugins.css index e18aaa774..fe235f089 100644 --- a/extensions/eclipse-che-theia-plugin-ext/src/browser/style/che-plugins.css +++ b/extensions/eclipse-che-theia-plugin-ext/src/browser/style/che-plugins.css @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (c) 2018-2020 Red Hat, Inc. + * Copyright (c) 2018-2022 Red Hat, Inc. * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -230,7 +230,7 @@ top: 58px; height: 20px; line-height: 20px; - right: 85px; + right: 95px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -253,10 +253,12 @@ | Plugin actions |----------------------------------------------------------------------------*/ -.che-plugin-list .che-plugin-action-add, -.che-plugin-list .che-plugin-action-remove, +.che-plugin-list .che-plugin-action-install, .che-plugin-list .che-plugin-action-installing, -.che-plugin-list .che-plugin-action-removing { +.che-plugin-list .che-plugin-action-installed, +.che-plugin-list .che-plugin-action-to-be-installed, +.che-plugin-list .che-plugin-action-removing, +.che-plugin-list .che-plugin-action-to-be-removed { display: block; position: absolute; font-size: var(--theia-ui-font-size0); @@ -266,49 +268,97 @@ height: 22px; line-height: 22px; right: 4px; - width: 75px; + width: 85px; text-align: center; user-select: none; border-radius: 1px; } -.che-plugin-list .che-plugin-action-add { +/************************************************************************* + * + * Install + * + *************************************************************************/ +.che-plugin-list .che-plugin-action-install { cursor: pointer; color: var(--theia-button-foreground); background-color: var(--theia-button-background); } -.che-plugin-list .che-plugin-action-add:hover { +.che-plugin-list .che-plugin-action-install:hover { background-color: var(--theia-button-hoverBackground); } -.che-plugin-list .che-plugin-action-add:active { +.che-plugin-list .che-plugin-action-install:active { box-shadow: 0px 0px 1px 1px var(--theia-button-hoverBackground); outline: none; } -.che-plugin-list .che-plugin-action-remove { +/************************************************************************* + * + * Installing + * + *************************************************************************/ + +.che-plugin-list .che-plugin-action-installing { + color: var(--theia-secondaryButton-disabledForeground); + background-color: var(--theia-secondaryButton-disabledBackground); +} + +/************************************************************************* + * + * Installed, To be Installed + * + *************************************************************************/ + +.che-plugin-list .che-plugin-action-installed, +.che-plugin-list .che-plugin-action-to-be-installed { cursor: pointer; color: var(--theia-inputValidation-warningBackground); background-color: var(--theia-successBackground); } -.che-plugin-list .che-plugin-action-remove:hover { +.che-plugin-list .che-plugin-action-installed:hover, +.che-plugin-list .che-plugin-action-to-be-installed:hover { color: var(--theia-secondaryButton-foreground); background-color: var(--theia-secondaryButton-hoverBackground); } -.che-plugin-list .che-plugin-action-remove:active { +.che-plugin-list .che-plugin-action-installed:active, +.che-plugin-list .che-plugin-action-to-be-installed:active { box-shadow: 0px 0px 1px 1px var(--theia-successBackground); outline: none; } -.che-plugin-list .che-plugin-action-installing { - color: var(--theia-secondaryButton-disabledForeground); - background-color: var(--theia-secondaryButton-disabledBackground); -} +/************************************************************************* + * + * Removing + * + *************************************************************************/ .che-plugin-list .che-plugin-action-removing { color: var(--theia-secondaryButton-disabledForeground); background-color: var(--theia-secondaryButton-disabledBackground); } + +/************************************************************************* + * + * To be Removed + * + *************************************************************************/ + +.che-plugin-list .che-plugin-action-to-be-removed { + cursor: pointer; + color: var(--theia-warningForeground); + background-color: var(--theia-warningBackground); +} + +.che-plugin-list .che-plugin-action-to-be-removed:hover { + color: var(--theia-secondaryButton-foreground); + background-color: var(--theia-secondaryButton-hoverBackground); +} + +.che-plugin-list .che-plugin-action-to-be-removed:active { + box-shadow: 0px 0px 1px 1px var(--theia-successBackground); + outline: none; +} diff --git a/extensions/eclipse-che-theia-plugin-ext/src/node/che-backend-module.ts b/extensions/eclipse-che-theia-plugin-ext/src/node/che-backend-module.ts index 35ecca27c..21ab34d61 100644 --- a/extensions/eclipse-che-theia-plugin-ext/src/node/che-backend-module.ts +++ b/extensions/eclipse-che-theia-plugin-ext/src/node/che-backend-module.ts @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (c) 2018-2020 Red Hat, Inc. + * Copyright (c) 2018-2022 Red Hat, Inc. * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -17,7 +17,6 @@ import { CheTaskClient, CheTaskService, } from '../common/che-protocol'; -import { CHE_PLUGIN_SERVICE_PATH, ChePluginService, ChePluginServiceClient } from '../common/che-plugin-protocol'; import { ConnectionHandler, JsonRpcConnectionHandler } from '@theia/core'; import { BackendApplicationContribution } from '@theia/core/lib/node/backend-application'; @@ -26,14 +25,13 @@ import { CheEnvVariablesServerImpl } from './che-env-variables-server'; import { CheGithubServiceImpl } from './che-github-service'; import { ChePluginApiContribution } from './che-plugin-script-service'; import { ChePluginApiProvider } from './che-plugin-api-provider'; -import { ChePluginServiceImpl } from './che-plugin-service'; import { CheProductServiceImpl } from './che-product-service'; import { CheTaskServiceImpl } from './che-task-service'; import { ContainerModule } from 'inversify'; import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; import { ExtPluginApiProvider } from '@theia/plugin-ext'; -import { PluginApiContribution } from '@theia/plugin-ext/lib/main/node/plugin-service'; -import { PluginApiContributionIntercepted } from './plugin-service'; +import { PluginApiContribution as WsRequestValidatorContributionImpl } from '@theia/plugin-ext/lib/main/node/plugin-service'; +import { WsRequestValidatorContributionIntercepted } from './ws-request-validator-contribution'; export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(CheEnvVariablesServerImpl).toSelf().inSingletonScope(); @@ -47,7 +45,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(BackendApplicationContribution).toService(ChePluginApiContribution); bind(BackendApplicationContribution).toService(CheClientIpServiceContribution); - rebind(PluginApiContribution).to(PluginApiContributionIntercepted).inSingletonScope(); + rebind(WsRequestValidatorContributionImpl).to(WsRequestValidatorContributionIntercepted).inSingletonScope(); bind(CheTaskService) .toDynamicValue(ctx => new CheTaskServiceImpl(ctx.container)) @@ -64,21 +62,6 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { ) .inSingletonScope(); - bind(ChePluginService) - .toDynamicValue(ctx => new ChePluginServiceImpl(ctx.container)) - .inSingletonScope(); - bind(ConnectionHandler) - .toDynamicValue( - ctx => - new JsonRpcConnectionHandler(CHE_PLUGIN_SERVICE_PATH, client => { - const server: ChePluginService = ctx.container.get(ChePluginService); - server.setClient(client); - client.onDidCloseConnection(() => server.disconnectClient(client)); - return server; - }) - ) - .inSingletonScope(); - bind(CheProductService).to(CheProductServiceImpl).inSingletonScope(); bind(ConnectionHandler) .toDynamicValue( diff --git a/extensions/eclipse-che-theia-plugin-ext/src/node/plugin-service.ts b/extensions/eclipse-che-theia-plugin-ext/src/node/ws-request-validator-contribution.ts similarity index 92% rename from extensions/eclipse-che-theia-plugin-ext/src/node/plugin-service.ts rename to extensions/eclipse-che-theia-plugin-ext/src/node/ws-request-validator-contribution.ts index 0a461bfc3..40eae6406 100644 --- a/extensions/eclipse-che-theia-plugin-ext/src/node/plugin-service.ts +++ b/extensions/eclipse-che-theia-plugin-ext/src/node/ws-request-validator-contribution.ts @@ -16,16 +16,16 @@ import { inject, injectable } from 'inversify'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { EndpointService } from '@eclipse-che/theia-remote-api/lib/common/endpoint-service'; import { ILogger } from '@theia/core/lib/common/logger'; -import { PluginApiContribution } from '@theia/plugin-ext/lib/main/node/plugin-service'; import { SERVER_WEBVIEWS_ATTR_VALUE } from '../common/che-server-common'; import { WebviewExternalEndpoint } from '@theia/plugin-ext/lib/main/common/webview-protocol'; +import { PluginApiContribution as WsRequestValidatorContributionImpl } from '@theia/plugin-ext/lib/main/node/plugin-service'; const vhost = require('vhost'); const pluginPath = (process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE) + './theia/plugins/'; @injectable() -export class PluginApiContributionIntercepted extends PluginApiContribution { +export class WsRequestValidatorContributionIntercepted extends WsRequestValidatorContributionImpl { @inject(EndpointService) private endpointService: EndpointService; diff --git a/extensions/eclipse-che-theia-plugin/src/che-proposed.d.ts b/extensions/eclipse-che-theia-plugin/src/che-proposed.d.ts index 2ea248f11..b65d0eb8b 100644 --- a/extensions/eclipse-che-theia-plugin/src/che-proposed.d.ts +++ b/extensions/eclipse-che-theia-plugin/src/che-proposed.d.ts @@ -72,7 +72,8 @@ declare module '@eclipse-che/plugin' { } export interface Devfile { - apiVersion: string; + apiVersion?: string; + schemaVersion?: string; attributes?: { [attributeName: string]: string }; metadata: DevfileMetadata; projects?: DevfileProject[]; diff --git a/extensions/eclipse-che-theia-remote-api/package.json b/extensions/eclipse-che-theia-remote-api/package.json index 7446f8c18..996539bed 100644 --- a/extensions/eclipse-che-theia-remote-api/package.json +++ b/extensions/eclipse-che-theia-remote-api/package.json @@ -14,6 +14,7 @@ "dependencies": { "@kubernetes/client-node": "^0.12.1", "@theia/core": "next", + "js-yaml": "3.13.1", "inversify": "^5.0.1" }, "devDependencies": { diff --git a/extensions/eclipse-che-theia-remote-api/src/browser/che-remote-api-frontend-module.ts b/extensions/eclipse-che-theia-remote-api/src/browser/che-remote-api-frontend-module.ts index 4965a9acd..ad2d364e5 100644 --- a/extensions/eclipse-che-theia-remote-api/src/browser/che-remote-api-frontend-module.ts +++ b/extensions/eclipse-che-theia-remote-api/src/browser/che-remote-api-frontend-module.ts @@ -10,6 +10,7 @@ import { CertificateService, cheCertificateServicePath } from '../common/certificate-service'; import { CheK8SService, cheK8SServicePath } from '../common/k8s-service'; +import { ChePluginService, ChePluginServiceClient, chePluginServicePath } from '../common/plugin-service'; import { DashboardService, cheDashboardServicePath } from '../common'; import { DevfileService, cheDevfileServicePath } from '../common/devfile-service'; import { EndpointService, cheEndpointServicePath } from '../common/endpoint-service'; @@ -76,6 +77,14 @@ export default new ContainerModule(bind => { }) .inSingletonScope(); + bind(ChePluginService) + .toDynamicValue(ctx => { + const provider = ctx.container.get(WebSocketConnectionProvider); + const client: ChePluginServiceClient = ctx.container.get(ChePluginServiceClient); + return provider.createProxy(chePluginServicePath, client); + }) + .inSingletonScope(); + bind(CheK8SService) .toDynamicValue(ctx => { const provider = ctx.container.get(WebSocketConnectionProvider); diff --git a/extensions/eclipse-che-theia-remote-api/src/common/dashboard-service.ts b/extensions/eclipse-che-theia-remote-api/src/common/dashboard-service.ts index 99479b671..c227349df 100644 --- a/extensions/eclipse-che-theia-remote-api/src/common/dashboard-service.ts +++ b/extensions/eclipse-che-theia-remote-api/src/common/dashboard-service.ts @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (c) 2021 Red Hat, Inc. + * Copyright (c) 2021-2022 Red Hat, Inc. * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -14,4 +14,6 @@ export const DashboardService = Symbol('DashboardService'); export interface DashboardService { getDashboardUrl(): Promise; + + getEditorUrl(): Promise; } diff --git a/extensions/eclipse-che-theia-remote-api/src/common/devfile-service.ts b/extensions/eclipse-che-theia-remote-api/src/common/devfile-service.ts index 4222184d3..9702916c4 100644 --- a/extensions/eclipse-che-theia-remote-api/src/common/devfile-service.ts +++ b/extensions/eclipse-che-theia-remote-api/src/common/devfile-service.ts @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (c) 2021 Red Hat, Inc. + * Copyright (c) 2021-2022 Red Hat, Inc. * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -22,6 +22,7 @@ export interface DevfileComponentStatus { }; }; } + export interface DevfileMetadata { attributes?: { [attributeName: string]: string }; description?: string; @@ -34,13 +35,15 @@ export interface DevfileMetadata { } export interface Devfile { - apiVersion: string; + apiVersion?: string; + schemaVersion?: string; attributes?: { [attributeName: string]: string }; metadata: DevfileMetadata; projects?: DevfileProject[]; components?: DevfileComponent[]; commands?: DevfileCommand[]; } + export interface DevfileCommandGroup { isDefault?: boolean; kind: 'build' | 'run' | 'test' | 'debug'; diff --git a/extensions/eclipse-che-theia-remote-api/src/common/http-service.ts b/extensions/eclipse-che-theia-remote-api/src/common/http-service.ts index 26df1667f..ea48b5fd5 100644 --- a/extensions/eclipse-che-theia-remote-api/src/common/http-service.ts +++ b/extensions/eclipse-che-theia-remote-api/src/common/http-service.ts @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (c) 2021 Red Hat, Inc. + * Copyright (c) 2021-2022 Red Hat, Inc. * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -14,6 +14,11 @@ export const HttpService = Symbol('HttpService'); export interface HttpService { get(url: string): Promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get(url: string, responseType: 'text' | 'arraybuffer'): Promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any post(url: string, data?: any): Promise; + + head(url: string): Promise; } diff --git a/extensions/eclipse-che-theia-plugin-ext/src/node/che-plugin-service.ts b/extensions/eclipse-che-theia-remote-api/src/common/plugin-service-impl.ts similarity index 56% rename from extensions/eclipse-che-theia-plugin-ext/src/node/che-plugin-service.ts rename to extensions/eclipse-che-theia-remote-api/src/common/plugin-service-impl.ts index 5153ab2f6..ae5949e40 100644 --- a/extensions/eclipse-che-theia-plugin-ext/src/node/che-plugin-service.ts +++ b/extensions/eclipse-che-theia-remote-api/src/common/plugin-service-impl.ts @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (c) 2018-2020 Red Hat, Inc. + * Copyright (c) 2019-2022 Red Hat, Inc. * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -9,18 +9,16 @@ ***********************************************************************/ import { + Changes, ChePluginMetadata, ChePluginRegistries, ChePluginRegistry, ChePluginService, ChePluginServiceClient, -} from '../common/che-plugin-protocol'; -import { DevfileComponent, DevfileService } from '@eclipse-che/theia-remote-api/lib/common/devfile-service'; -import { WorkspaceService, WorkspaceSettings } from '@eclipse-che/theia-remote-api/lib/common/workspace-service'; -import { injectable, interfaces } from 'inversify'; +} from './plugin-service'; +import { inject, injectable } from 'inversify'; -import { HttpService } from '@eclipse-che/theia-remote-api/lib/common/http-service'; -import { PluginFilter } from '../common/plugin/plugin-filter'; +import { HttpService } from './http-service'; import URI from '@theia/core/lib/common/uri'; const yaml = require('js-yaml'); @@ -43,36 +41,17 @@ export interface ChePluginMetadataInternal { }; } -/** - * Workspace Settings :: Plugin Registry URI. - * Public URI to load registry resources, e.g. icons. - */ -const PLUGIN_REGISTRY_URL = 'cheWorkspacePluginRegistryUrl'; - -/** - * Workspace Settings :: Plugin Registry internal URI. - * Is used for cross-container communication and mostly for getting plugins metadata. - */ -const PLUGIN_REGISTRY_INTERNAL_URL = 'cheWorkspacePluginRegistryInternalUrl'; - @injectable() -export class ChePluginServiceImpl implements ChePluginService { - private workspaceService: WorkspaceService; - private devfileService: DevfileService; - private httpService: HttpService; +export abstract class PluginServiceImpl implements ChePluginService { + @inject(HttpService) + protected httpService: HttpService; - private defaultRegistry: ChePluginRegistry; + client: ChePluginServiceClient | undefined; - private client: ChePluginServiceClient | undefined; + protected defaultRegistry: ChePluginRegistry; private cachedPlugins: ChePluginMetadata[] = []; - constructor(container: interfaces.Container) { - this.workspaceService = container.get(WorkspaceService); - this.devfileService = container.get(DevfileService); - this.httpService = container.get(HttpService); - } - setClient(client: ChePluginServiceClient): void { this.client = client; } @@ -83,7 +62,7 @@ export class ChePluginServiceImpl implements ChePluginService { dispose(): void {} - normalizeEnding(uri: string): string { + trimTrailingSlash(uri: string): string { if (uri.endsWith('/')) { return uri.substring(0, uri.length - 1); } @@ -91,42 +70,6 @@ export class ChePluginServiceImpl implements ChePluginService { return uri; } - async getDefaultRegistry(): Promise { - if (this.defaultRegistry) { - return this.defaultRegistry; - } - - try { - const workspaceSettings: WorkspaceSettings = await this.workspaceService.getWorkspaceSettings(); - if (workspaceSettings) { - const publicUri = workspaceSettings[PLUGIN_REGISTRY_URL]; - const uri = workspaceSettings[PLUGIN_REGISTRY_INTERNAL_URL] || publicUri; - - if (publicUri) { - this.defaultRegistry = { - name: 'Eclipse Che plugins', - internalURI: this.normalizeEnding(uri), - publicURI: this.normalizeEnding(publicUri), - }; - - return this.defaultRegistry; - } - } - - return Promise.reject('Plugin registry is not configured'); - } catch (error) { - console.error(error); - return Promise.reject(`Unable to get default plugin registry URI. ${error.message}`); - } - } - - /** - * Removes plugins with type 'Che Editor' - */ - squeezeOutEditors(plugins: ChePluginMetadata[], filter: string): ChePluginMetadata[] { - return plugins.filter(plugin => 'Che Editor' !== plugin.type); - } - async sleep(miliseconds: number): Promise { return new Promise(resolve => { setTimeout(() => { @@ -135,6 +78,8 @@ export class ChePluginServiceImpl implements ChePluginService { }); } + abstract getDefaultRegistry(): Promise; + /** * Updates the plugin cache * @@ -202,24 +147,6 @@ export class ChePluginServiceImpl implements ChePluginService { await this.client.notifyCachingComplete(); } - /** - * Returns a list of available plugins on the plugin registry. - * - * @param filter filter - * @return list of available plugins - */ - async getPlugins(filter: string): Promise { - let pluginList: ChePluginMetadata[] = [...this.cachedPlugins]; - - // filter plugins - if (filter) { - pluginList = PluginFilter.filterPlugins(pluginList, filter); - } - - // remove editors - return this.squeezeOutEditors(pluginList, filter); - } - /** * Loads list of plugins from plugin registry. * @@ -353,127 +280,30 @@ export class ChePluginServiceImpl implements ChePluginService { } /** - * Removes /meta.yaml from the end of the plugin ID (reference) - */ - normalizeId(id: string): string { - if ((id.startsWith('http://') || id.startsWith('https://')) && id.endsWith('/meta.yaml')) { - id = id.substring(0, id.length - '/meta.yaml'.length); - } - - return id; - } - - /** - * Creates a plugin component for the given plugin ID (reference) - */ - createPluginComponent(id: string): DevfileComponent { - if (id.startsWith('http://') || id.startsWith('https://')) { - return { - plugin: { - url: `${id}/meta.yaml`, - }, - }; - } else { - return { - plugin: { - id: `${id}`, - }, - }; - } - } - - /** - * Returns list of plugins described in workspace configuration. + * Returns a list of available plugins on the plugin registry. + * + * @return list of available plugins */ - async getWorkspacePlugins(): Promise { - const devfile = await this.devfileService.get(); - const devfileComponents: DevfileComponent[] = devfile.components || []; - devfile.components = devfileComponents; - - const plugins: string[] = []; - devfileComponents.forEach(component => { - if (component.plugin) { - if (component.plugin.url) { - plugins.push(this.normalizeId(component.plugin.url)); - } else if (component.plugin.id) { - plugins.push(component.plugin.id); - } - } - }); - return plugins; + async getPlugins(): Promise { + // remove editors ( to be compatible with all versions of plugin registry ) + return this.cachedPlugins.filter(plugin => 'Che Editor' !== plugin.type); } - /** - * Sets new list of plugins to workspace configuration. - */ - async setWorkspacePlugins(plugins: string[]): Promise { - const devfile = await this.devfileService.get(); - const devfileComponents: DevfileComponent[] = devfile.components || []; - - const components = devfileComponents.filter(component => component.plugin !== undefined); + abstract getInstalledPlugins(): Promise; - components.forEach(component => { - const id = component.plugin!.url ? this.normalizeId(component.plugin!.url) : component.plugin?.id!; - const foundIndex = plugins.indexOf(id); - if (foundIndex >= 0) { - plugins.splice(foundIndex, 1); - } else { - devfileComponents.splice(devfileComponents.indexOf(component), 1); - } - }); + abstract installPlugin(pluginKey: string): Promise; - plugins.forEach((plugin: string) => { - devfileComponents.push(this.createPluginComponent(plugin)); - }); - devfile.components = devfileComponents; + abstract removePlugin(pluginKey: string): Promise; - await this.devfileService.updateDevfile(devfile); - } + abstract updatePlugin(oldPluginKey: string, newPluginKey: string): Promise; - /** - * Adds a plugin to workspace configuration. - */ - async addPlugin(pluginKey: string): Promise { - try { - const plugins: string[] = await this.getWorkspacePlugins(); - plugins.push(pluginKey); - await this.setWorkspacePlugins(plugins); - } catch (error) { - console.error(error); - return Promise.reject('Unable to install plugin ' + pluginKey + ' ' + error.message); - } + async deferredInstallation(): Promise { + return false; } - /** - * Removes a plugin from workspace configuration. - */ - async removePlugin(pluginKey: string): Promise { - try { - const plugins: string[] = await this.getWorkspacePlugins(); - const filteredPlugins = plugins.filter(p => p !== pluginKey); - await this.setWorkspacePlugins(filteredPlugins); - } catch (error) { - console.error(error); - return Promise.reject('Unable to remove plugin ' + pluginKey + ' ' + error.message); - } + async getUnpersistedChanges(): Promise { + return undefined; } - async updatePlugin(oldPluginKey: string, newPluginKey: string): Promise { - try { - // get existing plugins - const plugins: string[] = await this.getWorkspacePlugins(); - - // remove old plugin key - const filteredPlugins = plugins.filter(p => p !== oldPluginKey); - - // add new plugin key - filteredPlugins.push(newPluginKey); - - // set plugins - await this.setWorkspacePlugins(filteredPlugins); - } catch (error) { - console.error(error); - return Promise.reject(`Unable to update plugin from ${oldPluginKey} to ${newPluginKey}: ${error.message}`); - } - } + async persist(): Promise {} } diff --git a/extensions/eclipse-che-theia-plugin-ext/src/common/che-plugin-protocol.ts b/extensions/eclipse-che-theia-remote-api/src/common/plugin-service.ts similarity index 67% rename from extensions/eclipse-che-theia-plugin-ext/src/common/che-plugin-protocol.ts rename to extensions/eclipse-che-theia-remote-api/src/common/plugin-service.ts index 9afd63736..370f19ef9 100644 --- a/extensions/eclipse-che-theia-plugin-ext/src/common/che-plugin-protocol.ts +++ b/extensions/eclipse-che-theia-remote-api/src/common/plugin-service.ts @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (c) 2019-2020 Red Hat, Inc. + * Copyright (c) 2019-2022 Red Hat, Inc. * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -19,16 +19,6 @@ export interface ChePluginRegistry { publicURI: string; } -export interface ChePlugin { - publisher: string; - name: string; - version: string; - installed: boolean; - versionList: { - [version: string]: ChePluginMetadata; - }; -} - /** * Describes properties in plugin meta.yaml */ @@ -63,10 +53,14 @@ export interface ChePluginRegistries { [name: string]: ChePluginRegistry; } -export const CHE_PLUGIN_SERVICE_PATH = '/che-plugin-service'; +export interface Changes { + toInstall: string[]; + toRemove: string[]; +} -export const ChePluginService = Symbol('ChePluginService'); +export const chePluginServicePath = '/che-plugin-service'; +export const ChePluginService = Symbol('ChePluginService'); export interface ChePluginService extends JsonRpcServer { disconnectClient(client: ChePluginServiceClient): void; @@ -85,30 +79,54 @@ export interface ChePluginService extends JsonRpcServer /** * Returns a list of available plugins on the plugin registry. * - * @param filter filter * @return list of available plugins */ - getPlugins(filter: string): Promise; + getPlugins(): Promise; /** - * Returns list of plugins described in workspace configuration. + * Returns list of installed plugins. */ - getWorkspacePlugins(): Promise; + getInstalledPlugins(): Promise; /** - * Adds a plugin to workspace configuration. + * Adds a plugin to current workspace. + * + * @param plugin plugin id in format `publisher/name/version` */ - addPlugin(pluginKey: string): Promise; + installPlugin(plugin: string): Promise; /** - * Removes a plugin from workspace configuration. + * Removes a plugin from current workspace. + * + * @param plugin plugin id in format `publisher/name/version` + * Throws an error with explanation message if the plugin cannot be removed. */ - removePlugin(pluginKey: string): Promise; + removePlugin(plugin: string): Promise; /** - * Changes the plugin version. + * Updates the plugin / changes the plugin version. */ updatePlugin(oldPluginKey: string, newPluginKey: string): Promise; + + /** + * Returns true, if the plugin service does not apply changes instantly. + * To complete the installation, client must call persist(); + */ + deferredInstallation(): Promise; + + /** + * Returns list of changes, that have been performed by the user but not yet applied. + */ + getUnpersistedChanges(): Promise; + + /** + * Applies all the changes to the devfile. + */ + persist(): Promise; +} + +export interface PluginDependencies { + plugins: string[]; } export const ChePluginServiceClient = Symbol('ChePluginServiceClient'); @@ -137,4 +155,11 @@ export interface ChePluginServiceClient { * Called by Plugin Service when invalid plugin meta.yaml has been found while updating plugin cache. */ invalidPluginFound(pluginYaml: string): Promise; + + /** + * Called by Plugin Service to ask the user to install plugin dependencies. + * + * @param plugins list of dependencies + */ + askToInstallDependencies(dependencies: PluginDependencies): Promise; } diff --git a/extensions/eclipse-che-theia-remote-impl-che-server/src/node/che-dashboard-service-impl.ts b/extensions/eclipse-che-theia-remote-impl-che-server/src/node/che-dashboard-service-impl.ts index 068abd372..23ba0ffdb 100644 --- a/extensions/eclipse-che-theia-remote-impl-che-server/src/node/che-dashboard-service-impl.ts +++ b/extensions/eclipse-che-theia-remote-impl-che-server/src/node/che-dashboard-service-impl.ts @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (c) 2021 Red Hat, Inc. + * Copyright (c) 2021-2022 Red Hat, Inc. * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -16,4 +16,8 @@ export class CheDashboardServiceImpl implements DashboardService { async getDashboardUrl(): Promise { return process.env.CHE_DASHBOARD_URL; } + + async getEditorUrl(): Promise { + return undefined; + } } diff --git a/extensions/eclipse-che-theia-remote-impl-che-server/src/node/che-remote-impl-che-server-backend-module.ts b/extensions/eclipse-che-theia-remote-impl-che-server/src/node/che-remote-impl-che-server-backend-module.ts index be2b1ff18..bfe86e8d8 100644 --- a/extensions/eclipse-che-theia-remote-impl-che-server/src/node/che-remote-impl-che-server-backend-module.ts +++ b/extensions/eclipse-che-theia-remote-impl-che-server/src/node/che-remote-impl-che-server-backend-module.ts @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (c) 2020 Red Hat, Inc. + * Copyright (c) 2020-2022 Red Hat, Inc. * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -13,6 +13,11 @@ import { cheCertificateServicePath, } from '@eclipse-che/theia-remote-api/lib/common/certificate-service'; import { CheK8SService, cheK8SServicePath } from '@eclipse-che/theia-remote-api/lib/common/k8s-service'; +import { + ChePluginService, + ChePluginServiceClient, + chePluginServicePath, +} from '@eclipse-che/theia-remote-api/lib/common/plugin-service'; import { ConnectionHandler, JsonRpcConnectionHandler } from '@theia/core'; import { DashboardService, cheDashboardServicePath } from '@eclipse-che/theia-remote-api/lib/common/dashboard-service'; import { DevfileService, cheDevfileServicePath } from '@eclipse-che/theia-remote-api/lib/common/devfile-service'; @@ -33,6 +38,7 @@ import { CheServerEndpointServiceImpl } from './che-server-endpoint-service-impl import { CheServerFactoryServiceImpl } from './che-server-factory-service-impl'; import { CheServerHttpServiceImpl } from './che-server-http-service-impl'; import { CheServerOAuthServiceImpl } from './che-server-oauth-service-impl'; +import { CheServerPluginServiceImpl } from './che-server-plugin-service-impl'; import { CheServerRemoteApiImpl } from './che-server-remote-api-impl'; import { CheServerSshKeyServiceImpl } from './che-server-ssh-key-service-impl'; import { CheServerTelemetryServiceImpl } from './che-server-telemetry-service-impl'; @@ -56,7 +62,10 @@ export default new ContainerModule(bind => { bind(CheServerSshKeyServiceImpl).toSelf().inSingletonScope(); bind(CheServerTelemetryServiceImpl).toSelf().inSingletonScope(); bind(CheServerUserServiceImpl).toSelf().inSingletonScope(); + bind(CheServerWorkspaceServiceImpl).toSelf().inSingletonScope(); + bind(CheServerPluginServiceImpl).toSelf().inSingletonScope(); + bind(CheServerDevfileServiceImpl).toSelf().inSingletonScope(); bind(CheServerEndpointServiceImpl).toSelf().inSingletonScope(); bind(CheK8SServiceImpl).toSelf().inSingletonScope(); @@ -69,7 +78,10 @@ export default new ContainerModule(bind => { bind(SshKeyService).to(CheServerSshKeyServiceImpl).inSingletonScope(); bind(TelemetryService).to(CheServerTelemetryServiceImpl).inSingletonScope(); bind(UserService).to(CheServerUserServiceImpl).inSingletonScope(); + bind(WorkspaceService).to(CheServerWorkspaceServiceImpl).inSingletonScope(); + bind(ChePluginService).to(CheServerPluginServiceImpl).inSingletonScope(); + bind(CheK8SService).to(CheK8SServiceImpl).inSingletonScope(); bind(DevfileService).to(CheServerDevfileServiceImpl).inSingletonScope(); bind(EndpointService).to(CheServerEndpointServiceImpl).inSingletonScope(); @@ -110,6 +122,18 @@ export default new ContainerModule(bind => { ) .inSingletonScope(); + bind(ConnectionHandler) + .toDynamicValue( + ctx => + new JsonRpcConnectionHandler(chePluginServicePath, client => { + const server: ChePluginService = ctx.container.get(ChePluginService); + server.setClient(client); + client.onDidCloseConnection(() => server.disconnectClient(client)); + return server; + }) + ) + .inSingletonScope(); + bind(ConnectionHandler) .toDynamicValue(ctx => new JsonRpcConnectionHandler(cheK8SServicePath, () => ctx.container.get(CheK8SService))) .inSingletonScope(); diff --git a/extensions/eclipse-che-theia-remote-impl-che-server/src/node/che-server-http-service-impl.ts b/extensions/eclipse-che-theia-remote-impl-che-server/src/node/che-server-http-service-impl.ts index 9ca921fc6..6120d963e 100644 --- a/extensions/eclipse-che-theia-remote-impl-che-server/src/node/che-server-http-service-impl.ts +++ b/extensions/eclipse-che-theia-remote-impl-che-server/src/node/che-server-http-service-impl.ts @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (c) 2021 Red Hat, Inc. + * Copyright (c) 2021-2022 Red Hat, Inc. * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -23,12 +23,13 @@ export class CheServerHttpServiceImpl implements HttpService { @inject(CertificateService) private certificateService: CertificateService; - async get(uri: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async get(uri: string, responseType?: 'text' | 'arraybuffer'): Promise { const axiosInstance = await this.getAxiosInstance(uri); try { const response = await axiosInstance.get(uri, { transformResponse: [data => data], - responseType: 'text', + responseType: responseType || 'text', }); return response.data; } catch (error) { @@ -45,6 +46,10 @@ export class CheServerHttpServiceImpl implements HttpService { throw new Error('httpsService.post() not supported'); } + async head(uri: string): Promise { + throw new Error('httpsService.head() not supported'); + } + /** * Use proxy and/or certificates. */ diff --git a/extensions/eclipse-che-theia-remote-impl-che-server/src/node/che-server-plugin-service-impl.ts b/extensions/eclipse-che-theia-remote-impl-che-server/src/node/che-server-plugin-service-impl.ts new file mode 100644 index 000000000..ff47ef15f --- /dev/null +++ b/extensions/eclipse-che-theia-remote-impl-che-server/src/node/che-server-plugin-service-impl.ts @@ -0,0 +1,185 @@ +/********************************************************************** + * Copyright (c) 2022 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 + ***********************************************************************/ + +import { DevfileComponent, DevfileService } from '@eclipse-che/theia-remote-api/lib/common/devfile-service'; +import { WorkspaceService, WorkspaceSettings } from '@eclipse-che/theia-remote-api/lib/common/workspace-service'; +import { inject, injectable } from 'inversify'; + +import { ChePluginRegistry } from '@eclipse-che/theia-remote-api/lib/common/plugin-service'; +import { PluginServiceImpl } from '@eclipse-che/theia-remote-api/lib/common/plugin-service-impl'; + +/** + * Workspace Settings :: Plugin Registry URI. + * Public URI to load registry resources, e.g. icons. + */ +const PLUGIN_REGISTRY_URL = 'cheWorkspacePluginRegistryUrl'; + +/** + * Workspace Settings :: Plugin Registry internal URI. + * Is used for cross-container communication and mostly for getting plugins metadata. + */ +const PLUGIN_REGISTRY_INTERNAL_URL = 'cheWorkspacePluginRegistryInternalUrl'; + +@injectable() +export class CheServerPluginServiceImpl extends PluginServiceImpl { + @inject(WorkspaceService) + private workspaceService: WorkspaceService; + + @inject(DevfileService) + private devfileService: DevfileService; + + async getDefaultRegistry(): Promise { + if (this.defaultRegistry) { + return this.defaultRegistry; + } + + try { + const workspaceSettings: WorkspaceSettings = await this.workspaceService.getWorkspaceSettings(); + if (workspaceSettings) { + const publicUri = workspaceSettings[PLUGIN_REGISTRY_URL]; + const internalUri = workspaceSettings[PLUGIN_REGISTRY_INTERNAL_URL] || publicUri; + + if (publicUri) { + this.defaultRegistry = { + name: 'Eclipse Che plugins', + internalURI: this.trimTrailingSlash(internalUri), + publicURI: this.trimTrailingSlash(publicUri), + }; + + return this.defaultRegistry; + } + } + + return Promise.reject('Plugin registry is not configured'); + } catch (error) { + console.error(error); + return Promise.reject(`Unable to get default plugin registry URI. ${error.message}`); + } + } + + /** + * Removes /meta.yaml from the end of the plugin ID (reference) + */ + normalizeId(id: string): string { + if ((id.startsWith('http://') || id.startsWith('https://')) && id.endsWith('/meta.yaml')) { + id = id.substring(0, id.length - '/meta.yaml'.length); + } + + return id; + } + + /** + * Creates a plugin component for the given plugin ID (reference) + */ + createPluginComponent(id: string): DevfileComponent { + if (id.startsWith('http://') || id.startsWith('https://')) { + return { + plugin: { + url: `${id}/meta.yaml`, + }, + }; + } else { + return { + plugin: { + id: `${id}`, + }, + }; + } + } + + /** + * Returns list of installed plugins. + */ + async getInstalledPlugins(): Promise { + const devfile = await this.devfileService.get(); + const devfileComponents: DevfileComponent[] = devfile.components || []; + devfile.components = devfileComponents; + + const plugins: string[] = []; + devfileComponents.forEach(component => { + if (component.plugin) { + if (component.plugin.url) { + plugins.push(this.normalizeId(component.plugin.url)); + } else if (component.plugin.id) { + plugins.push(component.plugin.id); + } + } + }); + return plugins; + } + + /** + * Sets new list of plugins to workspace configuration. + */ + async setWorkspacePlugins(plugins: string[]): Promise { + const devfile = await this.devfileService.get(); + const devfileComponents: DevfileComponent[] = devfile.components || []; + + const components = devfileComponents.filter(component => component.plugin !== undefined); + + components.forEach(component => { + const id = component.plugin!.url ? this.normalizeId(component.plugin!.url) : component.plugin?.id!; + const foundIndex = plugins.indexOf(id); + if (foundIndex >= 0) { + plugins.splice(foundIndex, 1); + } else { + devfileComponents.splice(devfileComponents.indexOf(component), 1); + } + }); + + plugins.forEach((plugin: string) => { + devfileComponents.push(this.createPluginComponent(plugin)); + }); + devfile.components = devfileComponents; + + await this.devfileService.updateDevfile(devfile); + } + + /** + * Adds a plugin to current Che workspace. + */ + async installPlugin(pluginKey: string): Promise { + try { + const plugins: string[] = await this.getInstalledPlugins(); + plugins.push(pluginKey); + await this.setWorkspacePlugins(plugins); + return true; + } catch (error) { + console.error(error); + return Promise.reject('Unable to install plugin ' + pluginKey + ' ' + error.message); + } + } + + /** + * Removes a plugin from workspace configuration. + */ + async removePlugin(pluginKey: string): Promise { + try { + const plugins: string[] = await this.getInstalledPlugins(); + const filteredPlugins = plugins.filter(p => p !== pluginKey); + await this.setWorkspacePlugins(filteredPlugins); + } catch (error) { + console.error(error); + return Promise.reject('Unable to remove plugin ' + pluginKey + ' ' + error.message); + } + } + + async updatePlugin(oldPluginKey: string, newPluginKey: string): Promise { + try { + const plugins: string[] = await this.getInstalledPlugins(); + const filteredPlugins = plugins.filter(p => p !== oldPluginKey); + filteredPlugins.push(newPluginKey); + await this.setWorkspacePlugins(filteredPlugins); + } catch (error) { + console.error(error); + return Promise.reject(`Unable to update plugin from ${oldPluginKey} to ${newPluginKey}: ${error.message}`); + } + } +} diff --git a/extensions/eclipse-che-theia-remote-impl-k8s/package.json b/extensions/eclipse-che-theia-remote-impl-k8s/package.json index 875304705..40e2094cd 100644 --- a/extensions/eclipse-che-theia-remote-impl-k8s/package.json +++ b/extensions/eclipse-che-theia-remote-impl-k8s/package.json @@ -26,7 +26,8 @@ "request": "2.82.0", "@eclipse-che/workspace-telemetry-client": "latest", "@kubernetes/client-node": "^0.12.1", - "@eclipse-che/theia-remote-api": "^0.0.1" + "@eclipse-che/theia-remote-api": "^0.0.1", + "jsonc-parser": "^3.0.0" }, "scripts": { "prepare": "yarn clean && yarn build && yarn test", diff --git a/extensions/eclipse-che-theia-remote-impl-k8s/src/node/k8s-backend-module.ts b/extensions/eclipse-che-theia-remote-impl-k8s/src/node/k8s-backend-module.ts index b073aaf55..d198ac3c0 100644 --- a/extensions/eclipse-che-theia-remote-impl-k8s/src/node/k8s-backend-module.ts +++ b/extensions/eclipse-che-theia-remote-impl-k8s/src/node/k8s-backend-module.ts @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (c) 2021 Red Hat, Inc. + * Copyright (c) 2021-2022 Red Hat, Inc. * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -13,6 +13,11 @@ import { cheCertificateServicePath, } from '@eclipse-che/theia-remote-api/lib/common/certificate-service'; import { CheK8SService, cheK8SServicePath } from '@eclipse-che/theia-remote-api/lib/common/k8s-service'; +import { + ChePluginService, + ChePluginServiceClient, + chePluginServicePath, +} from '@eclipse-che/theia-remote-api/lib/common/plugin-service'; import { ConnectionHandler, JsonRpcConnectionHandler } from '@theia/core'; import { DashboardService, cheDashboardServicePath } from '@eclipse-che/theia-remote-api/lib/common/dashboard-service'; import { DevfileService, cheDevfileServicePath } from '@eclipse-che/theia-remote-api/lib/common/devfile-service'; @@ -35,6 +40,7 @@ import { K8sDevfileServiceImpl } from './k8s-devfile-service-impl'; import { K8sEndpointServiceImpl } from './k8s-endpoint-service-impl'; import { K8sFactoryServiceImpl } from './k8s-factory-service-impl'; import { K8sOAuthServiceImpl } from './k8s-oauth-service-impl'; +import { K8sPluginServiceImpl } from './k8s-plugin-service-impl'; import { K8sSshKeyServiceImpl } from './k8s-ssh-key-service-impl'; import { K8sTelemetryServiceImpl } from './k8s-telemetry-service-impl'; import { K8sUserServiceImpl } from './k8s-user-service-impl'; @@ -53,7 +59,9 @@ export default new ContainerModule(bind => { bind(K8sSshKeyServiceImpl).toSelf().inSingletonScope(); bind(K8sTelemetryServiceImpl).toSelf().inSingletonScope(); bind(K8sUserServiceImpl).toSelf().inSingletonScope(); + bind(K8sWorkspaceServiceImpl).toSelf().inSingletonScope(); + bind(K8SServiceImpl).toSelf().inSingletonScope(); bind(K8sDevfileServiceImpl).toSelf().inSingletonScope(); bind(K8sEndpointServiceImpl).toSelf().inSingletonScope(); @@ -67,7 +75,9 @@ export default new ContainerModule(bind => { bind(SshKeyService).to(K8sSshKeyServiceImpl).inSingletonScope(); bind(TelemetryService).to(K8sTelemetryServiceImpl).inSingletonScope(); bind(UserService).to(K8sUserServiceImpl).inSingletonScope(); + bind(WorkspaceService).to(K8sWorkspaceServiceImpl).inSingletonScope(); + bind(CheK8SService).to(K8SServiceImpl).inSingletonScope(); bind(DevfileService).to(K8sDevfileServiceImpl).inSingletonScope(); bind(EndpointService).to(K8sEndpointServiceImpl).inSingletonScope(); @@ -108,6 +118,21 @@ export default new ContainerModule(bind => { ) .inSingletonScope(); + bind(K8sPluginServiceImpl).toSelf().inSingletonScope(); + bind(ChePluginService).to(K8sPluginServiceImpl).inSingletonScope(); + + bind(ConnectionHandler) + .toDynamicValue( + ctx => + new JsonRpcConnectionHandler(chePluginServicePath, client => { + const server: ChePluginService = ctx.container.get(ChePluginService); + server.setClient(client); + client.onDidCloseConnection(() => server.disconnectClient(client)); + return server; + }) + ) + .inSingletonScope(); + bind(ConnectionHandler) .toDynamicValue(ctx => new JsonRpcConnectionHandler(cheK8SServicePath, () => ctx.container.get(CheK8SService))) .inSingletonScope(); diff --git a/extensions/eclipse-che-theia-remote-impl-k8s/src/node/k8s-dashboard-service-impl.ts b/extensions/eclipse-che-theia-remote-impl-k8s/src/node/k8s-dashboard-service-impl.ts index 2eb8944ea..073705971 100644 --- a/extensions/eclipse-che-theia-remote-impl-k8s/src/node/k8s-dashboard-service-impl.ts +++ b/extensions/eclipse-che-theia-remote-impl-k8s/src/node/k8s-dashboard-service-impl.ts @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (c) 2021 Red Hat, Inc. + * Copyright (c) 2021-2022 Red Hat, Inc. * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -21,4 +21,12 @@ export class K8sDashboardServiceImpl implements DashboardService { async getDashboardUrl(): Promise { return this.k8sDevWorkspaceEnvVariables.getDashboardURL(); } + + async getEditorUrl(): Promise { + const dashboardURL = this.k8sDevWorkspaceEnvVariables.getDashboardURL(); + const namespace = this.k8sDevWorkspaceEnvVariables.getWorkspaceNamespace(); + const workspaceName = this.k8sDevWorkspaceEnvVariables.getWorkspaceName(); + + return `${dashboardURL}/dashboard/#/ide/${namespace}/${workspaceName}`; + } } diff --git a/extensions/eclipse-che-theia-remote-impl-k8s/src/node/k8s-devfile-service-impl.ts b/extensions/eclipse-che-theia-remote-impl-k8s/src/node/k8s-devfile-service-impl.ts index 9e5d27cbf..dcab63b49 100644 --- a/extensions/eclipse-che-theia-remote-impl-k8s/src/node/k8s-devfile-service-impl.ts +++ b/extensions/eclipse-che-theia-remote-impl-k8s/src/node/k8s-devfile-service-impl.ts @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (c) 2021 Red Hat, Inc. + * Copyright (c) 2021-2022 Red Hat, Inc. * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -39,7 +39,24 @@ export class K8sDevfileServiceImpl implements DevfileService { return devfileContent; } - async get(): Promise { + async get(fromCustomObject?: boolean): Promise { + if (fromCustomObject) { + const customObjectsApi = this.k8SService.makeApiClient(k8s.CustomObjectsApi); + const group = 'workspace.devfile.io'; + const version = 'v1alpha2'; + + const response = await customObjectsApi.getNamespacedCustomObject( + group, + version, + this.env.getWorkspaceNamespace(), + 'devworkspaces', + this.env.getWorkspaceName() + ); + + const body: { spec?: { template: Devfile } } = response.body; + return body.spec!.template; + } + // get raw content const devfileRaw = await this.getRaw(); return jsYaml.safeLoad(devfileRaw) as Devfile; @@ -133,6 +150,10 @@ export class K8sDevfileServiceImpl implements DevfileService { } async updateDevfile(devfile: Devfile): Promise { + await this.patch('/spec/template', devfile); + } + + async patch(path: string, newValue: object): Promise { // Grab custom resource object const customObjectsApi = this.k8SService.makeApiClient(k8s.CustomObjectsApi); const group = 'workspace.devfile.io'; @@ -141,8 +162,8 @@ export class K8sDevfileServiceImpl implements DevfileService { const patch = [ { op: 'replace', - path: '/spec/template', - value: devfile, + path: path, + value: newValue, }, ]; const options = { @@ -150,6 +171,7 @@ export class K8sDevfileServiceImpl implements DevfileService { 'Content-type': k8s.PatchUtils.PATCH_FORMAT_JSON_PATCH, }, }; + await customObjectsApi.patchNamespacedCustomObject( group, version, diff --git a/extensions/eclipse-che-theia-remote-impl-k8s/src/node/k8s-http-service-impl.ts b/extensions/eclipse-che-theia-remote-impl-k8s/src/node/k8s-http-service-impl.ts index 70d1c2507..47b9aeb38 100644 --- a/extensions/eclipse-che-theia-remote-impl-k8s/src/node/k8s-http-service-impl.ts +++ b/extensions/eclipse-che-theia-remote-impl-k8s/src/node/k8s-http-service-impl.ts @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (c) 2021 Red Hat, Inc. + * Copyright (c) 2021-2022 Red Hat, Inc. * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -23,12 +23,13 @@ export class K8SHttpServiceImpl implements HttpService { @inject(CertificateService) private certificateService: CertificateService; - async get(uri: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async get(uri: string, responseType?: 'text' | 'arraybuffer'): Promise { const axiosInstance = await this.getAxiosInstance(uri); try { const response = await axiosInstance.get(uri, { transformResponse: [data => data], - responseType: 'text', + responseType: responseType || 'text', }); return response.data; } catch (error) { @@ -58,6 +59,20 @@ export class K8SHttpServiceImpl implements HttpService { } } + async head(uri: string): Promise { + const axiosInstance = await this.getAxiosInstance(uri); + try { + await axiosInstance.head(uri); + return true; + } catch (error) { + // not found then we return false + if (error.response && error.response.status === 404) { + return false; + } + throw error; + } + } + /** * Use proxy and/or certificates. */ diff --git a/extensions/eclipse-che-theia-remote-impl-k8s/src/node/k8s-plugin-service-impl.ts b/extensions/eclipse-che-theia-remote-impl-k8s/src/node/k8s-plugin-service-impl.ts new file mode 100644 index 000000000..9d447f86b --- /dev/null +++ b/extensions/eclipse-che-theia-remote-impl-k8s/src/node/k8s-plugin-service-impl.ts @@ -0,0 +1,781 @@ +/********************************************************************** + * Copyright (c) 2022 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 + ***********************************************************************/ + +import * as fs from 'fs-extra'; +import * as jsYaml from 'js-yaml'; +import * as jsoncparser from 'jsonc-parser'; +import * as path from 'path'; + +import { Changes, ChePluginRegistry } from '@eclipse-che/theia-remote-api/lib/common/plugin-service'; +import { Devfile, DevfileComponent } from '@eclipse-che/theia-remote-api/lib/common/devfile-service'; +import { inject, injectable, postConstruct } from 'inversify'; + +import { K8sDevWorkspaceEnvVariables } from './k8s-devworkspace-env-variables'; +import { K8sDevfileServiceImpl } from './k8s-devfile-service-impl'; +import { K8sWorkspaceServiceImpl } from './k8s-workspace-service-impl'; +import { PluginServiceImpl } from '@eclipse-che/theia-remote-api/lib/common/plugin-service-impl'; + +export const CHE_PLUGINS_JSON = '/plugins/che-plugins.json'; + +export const PLUGINS_DIR = '/plugins'; +export const SIDECAR_PLUGINS_DIR = '/plugins/sidecars'; + +///////////////////////////////////////////////////////////////////////////////////////////////////////////// +// +// che-theia-plugin.yaml definition +// +///////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export namespace CheTheiaPlugin { + export interface Plugin { + schemaVersion: string; + metadata: Metadata; + sidecar?: Sidecar; + preferences?: Preferences; + dependencies?: string[]; + extensions?: string[]; + } + + export interface Metadata { + id: string; + publisher: string; + name: string; + version: string; + displayName: string; + description: string; + repository: string; + categories: string[]; + icon: string; + } + + export interface Sidecar { + name?: string; + memoryLimit?: string; + memoryRequest?: string; + cpuLimit?: string; + cpuRequest?: string; + volumeMounts?: VolumeMount[]; + image: string; + } + + export interface VolumeMount { + name: string; + path: string; + } + + export interface Preferences { + [name: string]: string; + } +} + +export interface Installation { + plugins: string[]; + + cache: { + [plugin: string]: CheTheiaPlugin.Plugin; + }; +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////// +// +// plugin.json definition +// +///////////////////////////////////////////////////////////////////////////////////////////////////////////// + +interface PluginList { + /** + * array of plugin identifiers + * identifier should be in format ${publisher}.${name} + */ + plugins: string[]; +} + +export class Deferred { + state: 'pending' | 'resolved' | 'rejected' = 'pending'; + resolve: (value?: T) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + reject: (err?: any) => void; + + promise = new Promise((resolve, reject) => { + this.resolve = result => { + resolve(result as T); + if (this.state === 'pending') { + this.state = 'resolved'; + } + }; + this.reject = err => { + reject(err); + if (this.state === 'pending') { + this.state = 'rejected'; + } + }; + }); +} + +@injectable() +export class K8sPluginServiceImpl extends PluginServiceImpl { + @inject(K8sDevWorkspaceEnvVariables) + env: K8sDevWorkspaceEnvVariables; + + @inject(K8sDevfileServiceImpl) + devfileService: K8sDevfileServiceImpl; + + @inject(K8sWorkspaceServiceImpl) + workspaceService: K8sWorkspaceServiceImpl; + + deferred = new Deferred(); + + @postConstruct() + protected startup(): void { + this.initialize() + .then(() => this.deferred.resolve()) + .catch(e => this.deferred.reject(e)); + } + + async initialize(): Promise { + const ERRMSG = 'Failure to initialize K8sPluginServiceImpl'; + + const projectsRoot = this.env.getProjectsRoot(); + + if (!projectsRoot) { + throw new Error(ERRMSG + 'Projects Root is not set.'); + } + + if (!(await fs.pathExists(projectsRoot))) { + throw new Error(ERRMSG + `Projects root [${projectsRoot}] does not exist`); + } + + if (await fs.pathExists(CHE_PLUGINS_JSON)) { + try { + const list = await this.readChePluginsJSON(); + for (const plugin of list.plugins) { + const parts: string[] = plugin.split('.'); + this.installedPlugins.push(`${parts[0]}/${parts[1]}/latest`); + } + } catch (error) { + throw new Error('Unable to get list of installed plugins. ' + error.message); + } + + return; + } + + try { + const pluginList: PluginList = { plugins: [] }; + + const projects = await fs.readdir(projectsRoot); + for (const project of projects) { + const extensionsJson = path.join(projectsRoot, project, '.vscode', 'extensions.json'); + + if ((await fs.pathExists(extensionsJson)) && (await fs.stat(extensionsJson)).isFile()) { + try { + const content = await fs.readFile(extensionsJson, 'utf-8'); + const strippedContent = jsoncparser.stripComments(content); + const extensions = jsoncparser.parse(strippedContent); + + const recommendations = extensions['recommendations']; + + const heap: Installation = { + plugins: [], + cache: {}, + }; + + for (const recommendation of recommendations) { + const parts: string[] = recommendation.split('.'); + + await this.prepareToInstall(`${parts[0]}/${parts[1]}/latest`, heap, false); + + for (const plugin of heap.plugins) { + const pluginParts: string[] = plugin.split('/'); + + if (!pluginList.plugins.find(value => value === `${pluginParts[0]}.${pluginParts[1]}`)) { + pluginList.plugins.push(`${pluginParts[0]}.${pluginParts[1]}`); + } + + if (!this.installedPlugins.find(value => value === plugin)) { + this.installedPlugins.push(plugin); + } + } + } + } catch (thisError) { + console.error( + `Unable to get list of extensions for ${project}. ${thisError.message ? thisError.message : thisError}` + ); + } + } + } + + await this.writeChePluginsJSON(pluginList); + } catch (error) { + throw new Error(ERRMSG + (error ? ' :: ' + (error.message ? error.message : error) : '')); + } + } + + /** + * Reads plugin list from /plugins/che-plugins.json + * + * @returns plugin list + */ + async readChePluginsJSON(): Promise { + const content = await fs.readFile(CHE_PLUGINS_JSON, 'utf-8'); + return JSON.parse(content); + } + + /** + * Writes plugin list to /plugins/che-plugins.json + * + * @param plugins plugin list + */ + async writeChePluginsJSON(plugins: PluginList): Promise { + const data = `${JSON.stringify(plugins, undefined, 2)}\n`; + await fs.writeFile(CHE_PLUGINS_JSON, data); + } + + /** + * Returns default plugin registry. + * + * @returns default plugin registry + */ + async getDefaultRegistry(): Promise { + if (this.defaultRegistry) { + return this.defaultRegistry; + } + + await this.deferred.promise; + + const registryURL = this.env.getPluginRegistryURL(); + const registryInternalURL = this.env.getPluginRegistryInternalURL(); + + if (!registryURL) { + throw new Error('Plugin registry URL is not configured.'); + } + + if (!registryInternalURL) { + throw new Error('Plugin registry internal URL is not configured.'); + } + + this.defaultRegistry = { + name: 'Eclipse Che plugins', + internalURI: this.trimTrailingSlash(registryInternalURL), + publicURI: this.trimTrailingSlash(registryURL), + }; + + return this.defaultRegistry; + } + + async getInstalledPlugins(): Promise { + await this.deferred.promise; + return this.installedPlugins; + } + + installedPlugins: string[] = []; + toInstall: string[] = []; + toRemove: string[] = []; + + async fetchCheTheiaPluginYaml(plugin: string): Promise { + const registryURL = this.trimTrailingSlash(this.env.getPluginRegistryInternalURL()); + const yamlURL = `${registryURL}/plugins/${plugin}/che-theia-plugin.yaml`; + const pluginYaml = await this.httpService.get(yamlURL); + + if (!pluginYaml) { + throw new Error(`Unable to get ${yamlURL}`); + } + + const yaml = jsYaml.load(pluginYaml); + + if (!yaml.metadata) { + throw new Error(`Failure to parse ${yamlURL}, wrong format.`); + } + + return yaml; + } + + async installPlugin(pluginId: string): Promise { + // check for existence in this.toRemove + if (this.toRemove.find(value => value === pluginId)) { + this.toRemove = this.toRemove.filter(value => value !== pluginId); + return true; + } + + const installation: Installation = { + plugins: [], + cache: {}, + }; + + await this.prepareToInstall(pluginId, installation, true); + + // if there are more than one plugin, it means that the plugin requires some dependencies + // and it is required to ask the user before installation + if (installation.plugins.length > 1) { + if (!this.client) { + return false; + } + + const dependencies = installation.plugins.slice(1, installation.plugins.length); + const toConfirm: string[] = []; + + const allPlugins = await this.getPlugins(); + + for (const dependency of dependencies) { + if (allPlugins.find(value => `${value.publisher}/${value.name}/${value.version}` === dependency)) { + toConfirm.push(dependency); + } + } + + if (toConfirm.length) { + const confirmed = await this.client.askToInstallDependencies({ + plugins: toConfirm, + }); + + if (!confirmed) { + return false; + } + } + } + + for (const plugin of installation.plugins) { + this.toInstall.push(plugin); + } + + return true; + } + + /** + * Prepares the plugin to installing: + * - analizes che-theia-plugin.yaml + * - checks .vsix existence + * - recursively handles dependencies + * + * All the necessary informaion will be stored inside Installation object + * + * @param pluginId plugin to install, should be in format ${publisher}/${name}/${version} + * @param installation storage for changes + * @param vsixCheck if true, each .vsix resource will be checked on existence + * @returns true, if plugin with its dependencies can be installed + * false, if plugin contains some dependecnices, but the user rejected the installation + * + * Will throw an error with user-friendly message in case of failures. + */ + async prepareToInstall(pluginId: string, installation: Installation, vsixCheck: boolean): Promise { + // check for existence in this.installedPlugins + if (this.installedPlugins.find(value => value === pluginId)) { + // do nothing + return; + } + + // check for existence in this.toBeInstalled + if (this.toInstall.find(value => value === pluginId)) { + // do nothing + return; + } + + // check for existence in ${installation.plugins} + if (installation.plugins.find(value => value === pluginId)) { + // do nothing + return; + } + + const yaml = await this.fetchCheTheiaPluginYaml(pluginId); + installation.plugins.push(pluginId); + installation.cache[pluginId] = yaml; + + if (vsixCheck) { + // check existence of all .vsix files + if (yaml.extensions) { + for (const extension of yaml.extensions) { + if (!(await this.httpService.head(extension))) { + throw new Error(`Extension ${extension} does not exist.`); + } + } + } + } + + // handle dependencies + if (yaml.dependencies && yaml.dependencies.length) { + for (const dependency of yaml.dependencies) { + const parts = dependency.split('/'); + const dependencyId = `${parts[0]}/${parts[1]}/${parts.length === 3 ? parts[2] : 'latest'}`; + await this.prepareToInstall(dependencyId, installation, vsixCheck); + } + } + } + + async removePlugin(pluginToRemove: string): Promise { + // check for existence in this.toBeRemoved + if (this.toRemove.find(value => value === pluginToRemove)) { + return; + } + + // do nothing if the plugin is not installed, and not marked for installation + if ( + !this.installedPlugins.find(value => value === pluginToRemove) && + !this.toInstall.find(value => value === pluginToRemove) + ) { + return; + } + + const allPlugins = await this.getPlugins(); + + const dependentPlugins: string[] = []; + + const toCheck: string[] = []; + toCheck.push(...this.installedPlugins); + toCheck.push(...this.toInstall); + + for (const plugin of toCheck) { + if (plugin === pluginToRemove) { + continue; + } + + const yaml = await this.fetchCheTheiaPluginYaml(plugin); + if (yaml.dependencies) { + for (const dependency of yaml.dependencies) { + const parts = dependency.split('/'); + const version = parts.length === 3 ? parts[2] : 'latest'; + const dependencyId = `${parts[0]}/${parts[1]}/${version}`; + + if (dependencyId === pluginToRemove) { + dependentPlugins.push(plugin); + } + } + } + } + + const autoRemove: string[] = []; + + const filteredDependentPlugins: string[] = dependentPlugins.filter(dep => { + if (allPlugins.find(value => dep === `${value.publisher}/${value.name}/${value.version}`)) { + return true; + } else { + autoRemove.push(dep); + return false; + } + }); + + if (filteredDependentPlugins.length === 0) { + if (this.toInstall.find(value => value === pluginToRemove)) { + this.toInstall = this.toInstall.filter(value => value !== pluginToRemove); + } else { + this.toRemove.push(pluginToRemove); + } + + for (const plugin of autoRemove) { + if (!this.toRemove.find(value => value === plugin)) { + this.toRemove.push(plugin); + } + } + return; + } + + const plugins = filteredDependentPlugins.map((value, index) => `${index > 0 ? ' ' : ''}'${value}'`); + + const error = `Cannot remove '${pluginToRemove}'. ${ + plugins.length === 1 ? 'Plugin' : 'Plugins' + } ${plugins.toString()} ${plugins.length === 1 ? 'depends' : 'depend'} on this.`; + throw new Error(error); + } + + async getUnpersistedChanges(): Promise { + return { + toInstall: this.toInstall, + toRemove: this.toRemove, + }; + } + + async persist(): Promise { + if (this.toInstall.length === 0 && this.toRemove.length === 0) { + return; + } + + // get DevWorkspace template + const devfile = await this.devfileService.get(true); + const devContainer = this.findDevContainer(devfile); + + const installYamls: CheTheiaPlugin.Plugin[] = []; + const removeYamls: CheTheiaPlugin.Plugin[] = []; + + // walk through toInstall list, download all che-theia-plugin.yaml files + for (const plugin of this.toInstall) { + const yaml = await this.fetchCheTheiaPluginYaml(plugin); + installYamls.push(yaml); + } + + // walk through toRemove list, download all che-theia-plugin.yaml files + for (const plugin of this.toRemove) { + const yaml = await this.fetchCheTheiaPluginYaml(plugin); + removeYamls.push(yaml); + } + + await this.downloadExtensions(installYamls, devContainer.name!); + + await this.cleanupExtensions(removeYamls, devContainer.name!); + + let devContainerChanged = false; + + // walk through toRemove list, remove extensions from the devfile + for (const yaml of removeYamls) { + if (!yaml.sidecar) { + continue; + } + + if (await this.removePluginFromDevContainer(yaml, devContainer)) { + devContainerChanged = true; + } + } + + // walk through toInstall list, add extensions to the devfile + for (const yaml of installYamls) { + if (!yaml.sidecar) { + continue; + } + + if (await this.addPluginToDevContainer(yaml, devContainer)) { + devContainerChanged = true; + } + } + + // update and persist list of installed plugins ( /plugins/plugins.json ) + for (const plugin of this.toRemove) { + this.installedPlugins = this.installedPlugins.filter(value => value !== plugin); + } + + for (const plugin of this.toInstall) { + this.installedPlugins.push(plugin); + } + + this.toRemove = []; + this.toInstall = []; + + await this.persistInstalledPLuginsList(); + + if (devContainerChanged) { + await this.devfileService.patch('/spec/template/components', devfile.components!); + } + + await this.workspaceService.stop(); + } + + async persistInstalledPLuginsList(): Promise { + const pluginList: PluginList = { plugins: [] }; + for (const plugin of this.installedPlugins) { + const parts = plugin.split('/'); + pluginList.plugins.push(`${parts[0]}.${parts[1]}`); + } + await this.writeChePluginsJSON(pluginList); + } + + async removePluginFromDevContainer(plugin: CheTheiaPlugin.Plugin, devContainer: DevfileComponent): Promise { + if (!devContainer.attributes) { + return false; + } + + let extensionsAttribute: string[] = devContainer.attributes['che-theia.eclipse.org/vscode-extensions']; + if (!extensionsAttribute) { + return false; + } + + let changes = false; + if (plugin.extensions) { + for (const extension of plugin.extensions) { + extensionsAttribute = extensionsAttribute.filter(value => value !== extension); + } + + devContainer.attributes['che-theia.eclipse.org/vscode-extensions'] = extensionsAttribute; + changes = true; + } + + return changes; + } + + async addPluginToDevContainer(plugin: CheTheiaPlugin.Plugin, devContainer: DevfileComponent): Promise { + let changes = false; + + if (!devContainer.attributes) { + devContainer['attributes'] = {}; + } + + if (plugin.extensions) { + if (!devContainer.attributes['che-theia.eclipse.org/vscode-extensions']) { + devContainer.attributes['che-theia.eclipse.org/vscode-extensions'] = []; + } + + const extensionsAttribute: string[] = devContainer.attributes['che-theia.eclipse.org/vscode-extensions']; + for (const extension of plugin.extensions) { + if (!extensionsAttribute.find(value => value === extension)) { + extensionsAttribute.push(extension); + + changes = true; + } + } + } + + if (plugin.preferences) { + if (!devContainer.attributes['che-theia.eclipse.org/vscode-preferences']) { + devContainer.attributes['che-theia.eclipse.org/vscode-preferences'] = {}; + } + + const preferencesAttribute: { [preferenceName: string]: string } = + devContainer.attributes['che-theia.eclipse.org/vscode-preferences']; + for (const prefName of Object.keys(plugin.preferences)) { + const prefValue = plugin.preferences[prefName]; + preferencesAttribute[prefName] = prefValue; + + changes = true; + } + } + + return changes; + } + + async downloadExtensions(installYamls: CheTheiaPlugin.Plugin[], devContainerName: string): Promise { + const downloadedExtensions: string[] = []; + + for (const yaml of installYamls) { + // download .vsix files + if (yaml.extensions) { + for (const uri of yaml.extensions) { + const sidecar = yaml.sidecar ? devContainerName : undefined; + if (await this.isExtensionAlreadyDownloaded(uri, sidecar)) { + continue; + } + + try { + const extensionFile = await this.downloadExtensionToPluginsDirectory(uri, sidecar); + downloadedExtensions.push(extensionFile); + } catch (error) { + // rollback the installation + for (const file of downloadedExtensions) { + if (await fs.pathExists(file)) { + await fs.remove(file); + } + } + + throw error; + } + } + } + } + + return downloadedExtensions; + } + + async cleanupExtensions(removeYamls: CheTheiaPlugin.Plugin[], devContainerName: string): Promise { + const removedExtensions: string[] = []; + + for (const yaml of removeYamls) { + if (yaml.sidecar) { + // remove extension(s) from ${SIDECAR_PLUGINS_DIR}/{devContainerName} directory + if (yaml.extensions) { + for (const uri of yaml.extensions) { + const fileName = path.basename(uri); + const file = `${SIDECAR_PLUGINS_DIR}/${devContainerName}/${fileName}`; + if (await fs.pathExists(file)) { + await fs.remove(file); + } + } + } + } else { + // remove extension(s) from ${PLUGINS_DIR} directory + if (yaml.extensions) { + for (const uri of yaml.extensions) { + const fileName = path.basename(uri); + const file = `${PLUGINS_DIR}/${fileName}`; + if (await fs.pathExists(file)) { + await fs.remove(file); + } + } + } + } + } + + return removedExtensions; + } + + async isExtensionAlreadyDownloaded(url: string, sidecar?: string): Promise { + const target = sidecar + ? `${SIDECAR_PLUGINS_DIR}/${sidecar}/${path.basename(url)}` + : `${PLUGINS_DIR}/${path.basename(url)}`; + return fs.pathExists(target); + } + + async downloadExtensionToPluginsDirectory(url: string, sidecar?: string): Promise { + let target; + + if (sidecar) { + const directoryName = `${SIDECAR_PLUGINS_DIR}/${sidecar}`; + await fs.ensureDir(directoryName); + target = `${SIDECAR_PLUGINS_DIR}/${sidecar}/${path.basename(url)}`; + } else { + target = `${PLUGINS_DIR}/${path.basename(url)}`; + } + + const response = await this.httpService.get(url, 'arraybuffer'); + if (response) { + await fs.writeFile(target, response, { encoding: 'binary' }); + return target; + } + + throw new Error(`Failure to download ${url}`); + } + + findDevContainer(devfile: Devfile): DevfileComponent { + // need to find definition + const devContainerAttribute = 'che-theia.eclipse.org/dev-container'; + + // first search if we have an optional annotated container + const annotatedContainers = devfile.components?.filter( + component => + component.attributes && + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (component.attributes as any)[devContainerAttribute] === true + ); + if (annotatedContainers) { + if (annotatedContainers.length === 1) { + return annotatedContainers[0]; + } else if (annotatedContainers.length > 1) { + throw new Error(`Only one container can be annotated with ${devContainerAttribute}: true`); + } + } + + // search in main devWorkspace (exclude theia as component name) + const devComponents = devfile.components + ?.filter(component => component.container && component.name !== 'theia-ide') + .filter( + // we should ignore component that do not mount the sources + component => component.container && component.container.mountSources !== false + ) + .filter( + component => + !(component.attributes && component.attributes['app.kubernetes.io/part-of'] === 'che-theia.eclipse.org') + ); + + // only one, fine, else error + if (!devComponents || devComponents.length === 0) { + throw new Error('Not able to find any dev container component in DevWorkspace'); + } else if (devComponents.length === 1) { + return devComponents[0]; + } else { + console.warn( + `More than one dev container component has been potentially found, taking the first one of ${devComponents.map( + component => component.name + )}` + ); + return devComponents[0]; + } + } + + async updatePlugin(oldPluginKey: string, newPluginKey: string): Promise { + console.log('Method [ updatePlugin ] not implemented.'); + throw new Error('Method not implemented.'); + } + + async deferredInstallation(): Promise { + return true; + } +} diff --git a/extensions/eclipse-che-theia-remote-impl-k8s/tests/_data/devworkspace-template.json b/extensions/eclipse-che-theia-remote-impl-k8s/tests/_data/devworkspace-template.json new file mode 100644 index 000000000..e9995cab9 --- /dev/null +++ b/extensions/eclipse-che-theia-remote-impl-k8s/tests/_data/devworkspace-template.json @@ -0,0 +1,143 @@ +{ + "commands": [ + { + "exec": { + "commandLine": "java -jar -Dspring-boot.run.profiles=mysql \\\n-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005 \\\ntarget/*.jar\n", + "component": "tools", + "group": { + "isDefault": true, + "kind": "run" + }, + "workingDir": "${PROJECTS_ROOT}/java-spring-petclinic" + }, + "id": "run-with-mysql" + }, + { + "exec": { + "commandLine": "java -jar -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005 target/*.jar", + "component": "tools", + "group": { + "isDefault": true, + "kind": "run" + }, + "workingDir": "${PROJECTS_ROOT}/java-spring-petclinic" + }, + "id": "run-debug" + } + ], + "components": [ + { + "container": { + "args": [ + "sh", + "-c", + "${PLUGIN_REMOTE_ENDPOINT_EXECUTABLE}" + ], + "endpoints": [ + { + "exposure": "none", + "name": "debug", + "protocol": "tcp", + "targetPort": 5005 + }, + { + "exposure": "public", + "name": "8080-tcp", + "protocol": "http", + "targetPort": 8080 + } + ], + "env": [ + { + "name": "PLUGIN_REMOTE_ENDPOINT_EXECUTABLE", + "value": "/remote-endpoint/plugin-remote-endpoint" + }, + { + "name": "THEIA_PLUGINS", + "value": "local-dir:///plugins/sidecars/tools" + } + ], + "image": "quay.io/devfile/universal-developer-image:ubi8-d433ed6", + "memoryLimit": "3Gi", + "sourceMapping": "/projects", + "volumeMounts": [ + { + "name": "m2", + "path": "/home/user/.m2" + }, + { + "name": "remote-endpoint", + "path": "/remote-endpoint" + }, + { + "name": "plugins", + "path": "/plugins" + } + ] + }, + "name": "tools" + }, + { + "name": "m2", + "volume": { + "size": "1G" + } + }, + { + "container": { + "endpoints": [ + { + "exposure": "internal", + "name": "db", + "protocol": "tcp", + "targetPort": 3306 + } + ], + "env": [ + { + "name": "MYSQL_USER", + "value": "petclinic" + }, + { + "name": "MYSQL_PASSWORD", + "value": "petclinic" + }, + { + "name": "MYSQL_DATABASE", + "value": "petclinic" + }, + { + "name": "PS1", + "value": "$(echo ${0})\\\\$" + } + ], + "image": "quay.io/eclipse/che--centos--mysql-57-centos7:latest-e08ee4d43b7356607685b69bde6335e27cf20c020f345b6c6c59400183882764", + "memoryLimit": "300Mi", + "sourceMapping": "/projects" + }, + "name": "mysql" + }, + { + "name": "theia-ide-workspace63092f2f66734133", + "plugin": { + "kubernetes": { + "name": "theia-ide-workspace63092f2f66734133", + "namespace": "admin-che" + } + } + } + ], + "projects": [ + { + "git": { + "checkoutFrom": { + "revision": "devfilev2" + }, + "remotes": { + "origin": "https://github.com/vitaliy-guliy/java-spring-petclinic.git" + } + }, + "name": "java-spring-petclinic" + } + ] +} diff --git a/extensions/eclipse-che-theia-remote-impl-k8s/tests/node/k8s-plugin-service-impl.spec.ts b/extensions/eclipse-che-theia-remote-impl-k8s/tests/node/k8s-plugin-service-impl.spec.ts new file mode 100644 index 000000000..f8794a3cb --- /dev/null +++ b/extensions/eclipse-che-theia-remote-impl-k8s/tests/node/k8s-plugin-service-impl.spec.ts @@ -0,0 +1,1722 @@ +/********************************************************************** + * Copyright (c) 2021-2022 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 + ***********************************************************************/ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import 'reflect-metadata'; + +import * as fs from 'fs-extra'; +import * as p from 'path'; + +import { CHE_PLUGINS_JSON, K8sPluginServiceImpl } from '../../src/node/k8s-plugin-service-impl'; +import { ChePluginMetadata, PluginDependencies } from '@eclipse-che/theia-remote-api/lib/common/plugin-service'; +import { Devfile, DevfileService } from '@eclipse-che/theia-remote-api/lib/common/devfile-service'; + +import { Container } from 'inversify'; +import { HttpService } from '@eclipse-che/theia-remote-api/lib/common/http-service'; +import { K8sDevWorkspaceEnvVariables } from '../../src/node/k8s-devworkspace-env-variables'; +import { K8sDevfileServiceImpl } from '../../src/node/k8s-devfile-service-impl'; +import { K8sWorkspaceServiceImpl } from '../../src/node/k8s-workspace-service-impl'; +import { WorkspaceService } from '@eclipse-che/theia-remote-api/lib/common/workspace-service'; + +const EXTENSIONS_JSON_WITH_JAVA_EXTENSION: string = `{ + // See https://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "redhat.java" + ] +} +`; + +const PLUGINS_JSON_WITH_THREE_JAVA_PLUGINS = `{ + "plugins": [ + "redhat.java", + "vscjava.vscode-java-debug", + "vscjava.vscode-java-test" + ] +} +`; + +const EMPTY_PLUGINS_JSON = `{ + "plugins": [] +} +`; + +export const PLUGINS_JSON_WITH_MERMAID_MARKDOWN_SYNTAX_HIGHLIGHTING_PLUGIN = `{ + "plugins": [ + "goddard.mermaid-markdown-syntax-highlighting" + ] +} +`; + +// goddard/mermaid-markdown-syntax-highlighting/latest +export const MERMAID_MARKDOWN_SYNTAX_HIGHLIGHTING_CHE_THEIA_PLUGIN_YAML = ` +schemaVersion: 1.0.0 +metadata: + id: goddard/mermaid-markdown-syntax-highlighting + publisher: goddard + name: mermaid-markdown-syntax-highlighting + version: latest + displayName: Mermaid Markdown Syntax Highlighting + description: Markdown syntax support for the Mermaid charting language + repository: 'https://github.com/bpruitt-goddard/vscode-mermaid-syntax-highlight.git' + categories: + - Other + icon: /images/default.png +dependencies: [] +extensions: + - 'https://somehost/vscode-mermaid-syntax-highlight.vsix' +`; + +// bierner/markdown-mermaid/latest +export const MARKDOWN_MERMAID_CHE_THEIA_PLUGIN_YAML = ` +schemaVersion: 1.0.0 +metadata: + id: bierner/markdown-mermaid + publisher: bierner + name: markdown-mermaid + version: latest + displayName: Markdown Preview Mermaid Support + description: Adds Mermaid diagram and flowchart support to VS Code's builtin markdown preview + repository: 'https://github.com/mjbvz/vscode-markdown-mermaid.git' + categories: + - Other + icon: /images/bierner-markdown-mermaid-icon.png +dependencies: + - goddard/mermaid-markdown-syntax-highlighting +extensions: + - 'https://somehost/vscode-markdown-mermaid-1.9.2.vsix' +`; + +// ex/markdown-mermaid-templates/latest +export const MARKDOWN_MERMAID_TEMPLATES_CHE_THEIA_PLUGIN_YAML = ` +schemaVersion: 1.0.0 +metadata: + id: ex/markdown-mermaid-templates + publisher: ex + name: markdown-mermaid-templates + version: latest + displayName: Templates for Markdown Preview Mermaid Support + description: Provides Templates for Mermaid plugin + repository: 'https://ex/vscode-markdown-templates.git' + categories: + - Other + icon: /images/ex-markdown-mermaid-templates-icon.png +dependencies: + - bierner/markdown-mermaid + - goddard/mermaid-markdown-syntax-highlighting +extensions: + - 'https://somehost/vscode-markdown-mermaid-templates-0.6.1.vsix' +`; + +// redhat/java/latest +export const JAVA_CHE_THEIA_PLUGIN_YAML = ` +schemaVersion: 1.0.0 +metadata: + id: redhat/java + publisher: redhat + name: java + version: latest + displayName: Language Support for Java(TM) by Red Hat + description: Java Linting, Intellisense, formatting, refactoring, Maven/Gradle support and more... + repository: https://github.com/redhat-developer/vscode-java + categories: + - Programming Languages + - Linters + - Formatters + - Snippets + icon: /images/redhat-java-icon.png +sidecar: + name: vscode-java + memoryLimit: 1500Mi + memoryRequest: 20Mi + cpuLimit: 800m + cpuRequest: 30m + volumeMounts: + - name: m2 + path: /home/theia/.m2 + image: quay.io/eclipse/che-plugin-sidecar:java-23e57d6 +preferences: + java.server.launchMode: Standard +dependencies: + - vscjava/vscode-java-debug + - vscjava/vscode-java-test +extensions: + - https://download.jboss.org/jbosstools/static/jdt.ls/stable/java-0.82.0-369.vsix +`; + +// vscjava/vscode-java-test/latest +export const VSCODE_JAVA_TEST_CHE_THEIA_PLUGIN = ` +schemaVersion: 1.0.0 +metadata: + id: vscjava/vscode-java-test + publisher: vscjava + name: vscode-java-test + version: latest + displayName: Java Test Runner + description: Run and debug JUnit or TestNG test cases + repository: https://github.com/Microsoft/vscode-java-test + categories: + - Other + icon: /images/vscjava-vscode-java-test-icon.png +sidecar: + name: vscode-java + memoryLimit: 1500Mi + memoryRequest: 20Mi + cpuLimit: 800m + cpuRequest: 30m + image: quay.io/eclipse/che-plugin-sidecar:java-23e57d6 +dependencies: + - redhat/java + - vscjava/vscode-java-debug +extensions: + - https://open-vsx.org/api/vscjava/vscode-java-test/0.28.1/file/vscjava.vscode-java-test-0.28.1.vsix +`; + +// vscjava/vscode-java-debug/latest +export const VSCODE_JAVA_DEBUG_CHE_THEIA_PLUGIN = ` +schemaVersion: 1.0.0 +metadata: + id: vscjava/vscode-java-debug + publisher: vscjava + name: vscode-java-debug + version: latest + displayName: Debugger for Java + description: A lightweight Java debugger for Visual Studio Code + repository: https://github.com/Microsoft/vscode-java-debug.git + categories: + - Debuggers + - Programming Languages + - Other + icon: /images/vscjava-vscode-java-debug-icon.png +sidecar: + name: vscode-java + memoryLimit: 1500Mi + memoryRequest: 20Mi + cpuLimit: 800m + cpuRequest: 30m + image: quay.io/eclipse/che-plugin-sidecar:java-23e57d6 +dependencies: [] +extensions: + - https://download.jboss.org/jbosstools/vscode/3rdparty/vscode-java-debug/vscode-java-debug-0.26.0.vsix +`; + +class TestDevfileService extends K8sDevfileServiceImpl {} + +class TestWorkspaceService extends K8sWorkspaceServiceImpl {} + +describe('Test K8sPluginServiceImpl', () => { + let container: Container; + + const mockGet = jest.fn(); + const mockHead = jest.fn(); + + const httpServiceMock: HttpService = { + get: mockGet, + post: jest.fn(), + head: mockHead, + }; + + let devfileService: TestDevfileService; + let workspaceService: TestWorkspaceService; + + const ORIGINAL_ENV = { ...process.env }; + + let devWorkspaceTemplateContent: string; + + beforeAll(async () => { + const devWorkspaceTemplatePath = p.resolve(__dirname, '..', '_data', 'devworkspace-template.json'); + devWorkspaceTemplateContent = await fs.readFile(devWorkspaceTemplatePath, 'utf-8'); + }); + + beforeEach(async () => { + jest.resetModules(); + + process.env = { ...ORIGINAL_ENV }; + process.env.PROJECTS_ROOT = '/projects'; + + // stubs + process.env.CHE_DASHBOARD_URL = '.'; + process.env.DEVWORKSPACE_FLATTENED_DEVFILE = '.'; + process.env.DEVWORKSPACE_NAME = '.'; + process.env.DEVWORKSPACE_NAMESPACE = '.'; + process.env.DEVWORKSPACE_ID = '.'; + process.env.CHE_PLUGIN_REGISTRY_URL = '.'; + process.env.CHE_PLUGIN_REGISTRY_INTERNAL_URL = '.'; + + jest.resetAllMocks(); + jest.resetModules(); + + Object.assign(fs, { + pathExists: async (path: string): Promise => path === '/projects', + readdir: async (path: string): Promise => [], + stat: async (path: string): Promise => Promise.reject(), + readFile: async (path: string): Promise => Promise.reject(), + writeFile: async (path: string, data: string): Promise => {}, + }); + + container = new Container(); + container.bind(HttpService).toConstantValue(httpServiceMock); + + devfileService = new TestDevfileService(); + workspaceService = new TestWorkspaceService(); + + container.bind(DevfileService).toConstantValue(devfileService); + container.bind(K8sDevfileServiceImpl).toConstantValue(devfileService); + + container.bind(WorkspaceService).toConstantValue(workspaceService); + container.bind(K8sWorkspaceServiceImpl).toConstantValue(workspaceService); + + container.bind(K8sDevWorkspaceEnvVariables).toSelf().inSingletonScope(); + container.bind(K8sPluginServiceImpl).toSelf().inSingletonScope(); + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + test('initialize :: should create /plugins/che-plugins.json with empty plugin list', async () => { + let testContent = ''; + + const writeFileMock = jest.fn(); + writeFileMock.mockImplementation(async (path: string, data: string): Promise => { + if (path === CHE_PLUGINS_JSON) { + testContent = data; + return; + } + + return Promise.reject(); + }); + + Object.assign(fs, { + pathExists: async (path: string): Promise => path === '/projects', + + readdir: async (path: string): Promise => { + if (path === '/projects') { + return ['project1']; + } + + return Promise.reject(); + }, + + writeFile: writeFileMock, + }); + + const pluginService = container.get(K8sPluginServiceImpl); + await pluginService.deferred.promise; + + expect(writeFileMock).toHaveBeenCalledTimes(1); + expect(JSON.parse(testContent)).toEqual(JSON.parse(EMPTY_PLUGINS_JSON)); + }); + + /** + * - extensions.json contains only redhat.java extension + * - redhat/java/latest che plugin depends on vscjava/vscode-java-debug and vscjava/vscode-java-test + * - after initializing, file /plugins/che-plugins.json should contain all there plugins + */ + test('initialize :: should handle all the plugin dependencies', async () => { + process.env.CHE_PLUGIN_REGISTRY_URL = 'http://public.uri/v3/'; + process.env.CHE_PLUGIN_REGISTRY_INTERNAL_URL = 'http://internal.uri/v3/'; + + let testContent = ''; + + Object.assign(fs, { + pathExists: async (path: string): Promise => + path === '/projects' || path === '/projects/project1/.vscode/extensions.json', + + readdir: async (path: string): Promise => { + if (path === '/projects') { + return ['project1']; + } + + return Promise.reject(); + }, + + stat: async (path: string): Promise => { + if (path === '/projects/project1/.vscode/extensions.json') { + return { + isFile: () => true, + } as fs.Stats; + } + + return Promise.reject(); + }, + + readFile: async (path: string): Promise => { + if (path === '/projects/project1/.vscode/extensions.json') { + return EXTENSIONS_JSON_WITH_JAVA_EXTENSION; + } + + return Promise.reject(); + }, + + writeFile: async (path: string, data: string): Promise => { + if (path === CHE_PLUGINS_JSON) { + testContent = data; + return; + } + + return Promise.reject(); + }, + }); + + mockGet.mockImplementation(async (url: string): Promise => { + switch (url) { + case 'http://internal.uri/v3/plugins/redhat/java/latest/che-theia-plugin.yaml': + return JAVA_CHE_THEIA_PLUGIN_YAML; + + case 'http://internal.uri/v3/plugins/vscjava/vscode-java-debug/latest/che-theia-plugin.yaml': + return VSCODE_JAVA_DEBUG_CHE_THEIA_PLUGIN; + + case 'http://internal.uri/v3/plugins/vscjava/vscode-java-test/latest/che-theia-plugin.yaml': + return VSCODE_JAVA_TEST_CHE_THEIA_PLUGIN; + } + + return undefined; + }); + + mockHead.mockImplementation( + async (url: string): Promise => + url === 'https://download.jboss.org/jbosstools/static/jdt.ls/stable/java-0.82.0-369.vsix' || + url === + 'https://download.jboss.org/jbosstools/vscode/3rdparty/vscode-java-debug/vscode-java-debug-0.26.0.vsix' || + url === 'https://open-vsx.org/api/vscjava/vscode-java-test/0.28.1/file/vscjava.vscode-java-test-0.28.1.vsix' + ); + + const pluginService = container.get(K8sPluginServiceImpl); + await pluginService.deferred.promise; + + expect(JSON.parse(testContent)).toEqual(JSON.parse(PLUGINS_JSON_WITH_THREE_JAVA_PLUGINS)); + }); + + test('initialize :: /plugins/che-plugins.json must not be overwritten if exists', async () => { + const writeFileMock = jest.fn(); + + Object.assign(fs, { + pathExists: async (path: string): Promise => { + if (path === '/projects' || path === CHE_PLUGINS_JSON) { + return true; + } + + return false; + }, + + readFile: async (path: string): Promise => { + if (path === CHE_PLUGINS_JSON) { + return EMPTY_PLUGINS_JSON; + } + + return Promise.reject(); + }, + + writeFile: writeFileMock, + }); + + const pluginService = container.get(K8sPluginServiceImpl); + + await pluginService.deferred.promise; + + expect(writeFileMock).toBeCalledTimes(0); + }); + + test('get default registry', async () => { + process.env.CHE_PLUGIN_REGISTRY_URL = 'http://public.uri/v3/'; + process.env.CHE_PLUGIN_REGISTRY_INTERNAL_URL = 'http://internal.uri/v3/'; + + const pluginService = container.get(K8sPluginServiceImpl); + + const defaultRegistry = await pluginService.getDefaultRegistry(); + + expect(defaultRegistry.name).toBe('Eclipse Che plugins'); + expect(defaultRegistry.publicURI).toBe('http://public.uri/v3'); + expect(defaultRegistry.internalURI).toBe('http://internal.uri/v3'); + }); + + test('get default registry :: failed on missing CHE_PLUGIN_REGISTRY_URL', async () => { + process.env.CHE_PLUGIN_REGISTRY_URL = undefined; + process.env.CHE_PLUGIN_REGISTRY_INTERNAL_URL = 'http://internal.uri/v3/'; + + const pluginService = container.get(K8sPluginServiceImpl); + + try { + await pluginService.getDefaultRegistry(); + fail(); + } catch (err) { + expect(err.message).toBe('Plugin registry URL is not configured.'); + } + }); + + test('get default registry :: failed on missing CHE_PLUGIN_REGISTRY_INTERNAL_URL', async () => { + process.env.CHE_PLUGIN_REGISTRY_URL = 'http://public.uri/v3/'; + process.env.CHE_PLUGIN_REGISTRY_INTERNAL_URL = undefined; + + const pluginService = container.get(K8sPluginServiceImpl); + + try { + await pluginService.getDefaultRegistry(); + fail(); + } catch (err) { + expect(err.message).toBe('Plugin registry internal URL is not configured.'); + } + }); + + test('get installed plugins :: should return three plugins', async () => { + Object.assign(fs, { + pathExists: async (path: string): Promise => { + if (path === '/projects' || path === CHE_PLUGINS_JSON) { + return true; + } + + return false; + }, + + readFile: async (path: string): Promise => { + if (path === CHE_PLUGINS_JSON) { + return PLUGINS_JSON_WITH_THREE_JAVA_PLUGINS; + } + + return Promise.reject(); + }, + }); + + const pluginService = container.get(K8sPluginServiceImpl); + await pluginService.deferred.promise; + + const plugins = await pluginService.getInstalledPlugins(); + expect(plugins).toEqual([ + 'redhat/java/latest', + 'vscjava/vscode-java-debug/latest', + 'vscjava/vscode-java-test/latest', + ]); + }); + + test('install plugin :: must skip if the plugin is already installed', async () => { + Object.assign(fs, { + pathExists: async (path: string): Promise => { + if (path === '/projects' || path === CHE_PLUGINS_JSON) { + return true; + } + + return false; + }, + + readFile: async (path: string): Promise => { + if (path === CHE_PLUGINS_JSON) { + return PLUGINS_JSON_WITH_MERMAID_MARKDOWN_SYNTAX_HIGHLIGHTING_PLUGIN; + } + + return Promise.reject(); + }, + }); + + const pluginService = container.get(K8sPluginServiceImpl); + await pluginService.deferred.promise; + + const prepareToInstallMock = jest.spyOn(pluginService, 'prepareToInstall'); + const fetchCheTheiaPluginYamlMock = jest.spyOn(pluginService, 'fetchCheTheiaPluginYaml'); + + await pluginService.installPlugin('goddard/mermaid-markdown-syntax-highlighting/latest'); + + expect(pluginService.installedPlugins).toEqual(['goddard/mermaid-markdown-syntax-highlighting/latest']); + expect(pluginService.toInstall).toEqual([]); + expect(pluginService.toRemove).toEqual([]); + + expect(prepareToInstallMock).toHaveBeenCalledTimes(1); + expect(fetchCheTheiaPluginYamlMock).toHaveBeenCalledTimes(0); + }); + + test('install plugin :: must remove from toRemove list if the plugin has been marked for removal', async () => { + Object.assign(fs, { + pathExists: async (path: string): Promise => { + if (path === '/projects' || path === CHE_PLUGINS_JSON) { + return true; + } + + return false; + }, + + readFile: async (path: string): Promise => { + if (path === CHE_PLUGINS_JSON) { + return PLUGINS_JSON_WITH_MERMAID_MARKDOWN_SYNTAX_HIGHLIGHTING_PLUGIN; + } + + return Promise.reject(); + }, + }); + + const pluginService = container.get(K8sPluginServiceImpl); + await pluginService.deferred.promise; + + pluginService.toRemove.push('goddard/mermaid-markdown-syntax-highlighting/latest'); + + await pluginService.installPlugin('goddard/mermaid-markdown-syntax-highlighting/latest'); + + const prepareToInstallMock = jest.spyOn(pluginService, 'prepareToInstall'); + const fetchCheTheiaPluginYamlMock = jest.spyOn(pluginService, 'fetchCheTheiaPluginYaml'); + + expect(pluginService.installedPlugins).toEqual(['goddard/mermaid-markdown-syntax-highlighting/latest']); + expect(pluginService.toInstall).toEqual([]); + expect(pluginService.toRemove).toEqual([]); + + expect(prepareToInstallMock).toHaveBeenCalledTimes(0); + expect(fetchCheTheiaPluginYamlMock).toHaveBeenCalledTimes(0); + }); + + test('install plugin :: should add plugin toInstall list', async () => { + process.env.CHE_PLUGIN_REGISTRY_URL = 'http://public.uri/v3/'; + process.env.CHE_PLUGIN_REGISTRY_INTERNAL_URL = 'http://internal.uri/v3/'; + + Object.assign(fs, { + pathExists: async (path: string): Promise => { + if (path === '/projects' || path === CHE_PLUGINS_JSON) { + return true; + } + + return false; + }, + + readFile: async (path: string): Promise => { + if (path === CHE_PLUGINS_JSON) { + return EMPTY_PLUGINS_JSON; + } + + return Promise.reject(); + }, + }); + + const pluginService = container.get(K8sPluginServiceImpl); + await pluginService.deferred.promise; + + mockGet.mockImplementation(async (url: string): Promise => { + if ( + url === + 'http://internal.uri/v3/plugins/goddard/mermaid-markdown-syntax-highlighting/latest/che-theia-plugin.yaml' + ) { + return MERMAID_MARKDOWN_SYNTAX_HIGHLIGHTING_CHE_THEIA_PLUGIN_YAML; + } + + return undefined; + }); + + mockHead.mockImplementation(async (url: string): Promise => { + if (url === 'https://somehost/vscode-mermaid-syntax-highlight.vsix') { + return true; + } + + return false; + }); + + const result = await pluginService.installPlugin('goddard/mermaid-markdown-syntax-highlighting/latest'); + expect(result).toBe(true); + + expect(pluginService.toInstall).toEqual(['goddard/mermaid-markdown-syntax-highlighting/latest']); + }); + + test('install plugin :: should handle plugin dependencies and add both plugins toInstall list', async () => { + process.env.CHE_PLUGIN_REGISTRY_URL = 'http://public.uri/v3/'; + process.env.CHE_PLUGIN_REGISTRY_INTERNAL_URL = 'http://internal.uri/v3/'; + + Object.assign(fs, { + pathExists: async (path: string): Promise => { + if (path === '/projects' || path === CHE_PLUGINS_JSON) { + return true; + } + + return false; + }, + + readFile: async (path: string): Promise => { + if (path === CHE_PLUGINS_JSON) { + return EMPTY_PLUGINS_JSON; + } + + return Promise.reject(); + }, + }); + + const pluginService = container.get(K8sPluginServiceImpl); + await pluginService.deferred.promise; + + mockGet.mockImplementation(async (url: string): Promise => { + if (url === 'http://internal.uri/v3/plugins/bierner/markdown-mermaid/latest/che-theia-plugin.yaml') { + return MARKDOWN_MERMAID_CHE_THEIA_PLUGIN_YAML; + } + + if ( + url === + 'http://internal.uri/v3/plugins/goddard/mermaid-markdown-syntax-highlighting/latest/che-theia-plugin.yaml' + ) { + return MERMAID_MARKDOWN_SYNTAX_HIGHLIGHTING_CHE_THEIA_PLUGIN_YAML; + } + + return undefined; + }); + + mockHead.mockImplementation(async (url: string): Promise => { + if ( + url === 'https://somehost/vscode-markdown-mermaid-1.9.2.vsix' || + url === 'https://somehost/vscode-mermaid-syntax-highlight.vsix' + ) { + return true; + } + + return false; + }); + + let askedDependencies; + + const askToInstallDependenciesMock = jest.fn(); + askToInstallDependenciesMock.mockImplementation(async (dependencies: PluginDependencies): Promise => { + askedDependencies = dependencies.plugins; + return true; + }); + + pluginService.setClient({ + notifyPluginCacheSizeChanged: jest.fn(), + notifyPluginCached: jest.fn(), + notifyCachingComplete: jest.fn(), + invalidRegistryFound: jest.fn(), + invalidPluginFound: jest.fn(), + askToInstallDependencies: askToInstallDependenciesMock, + }); + + const getPluginsMock = jest.spyOn(pluginService, 'getPlugins'); + getPluginsMock.mockImplementation( + async (): Promise => [ + { + publisher: 'goddard', + name: 'mermaid-markdown-syntax-highlighting', + version: 'latest', + } as ChePluginMetadata, + ] + ); + + const result = await pluginService.installPlugin('bierner/markdown-mermaid/latest'); + expect(result).toBe(true); + + expect(askedDependencies).toEqual(['goddard/mermaid-markdown-syntax-highlighting/latest']); + + expect(pluginService.toInstall).toEqual([ + 'bierner/markdown-mermaid/latest', + 'goddard/mermaid-markdown-syntax-highlighting/latest', + ]); + }); + + test('install plugin :: should cancel the installation if the user rejected installing the dependencies', async () => { + process.env.CHE_PLUGIN_REGISTRY_URL = 'http://public.uri/v3/'; + process.env.CHE_PLUGIN_REGISTRY_INTERNAL_URL = 'http://internal.uri/v3/'; + + Object.assign(fs, { + pathExists: async (path: string): Promise => { + if (path === '/projects' || path === CHE_PLUGINS_JSON) { + return true; + } + + return false; + }, + + readFile: async (path: string): Promise => { + if (path === CHE_PLUGINS_JSON) { + return EMPTY_PLUGINS_JSON; + } + + return Promise.reject(); + }, + }); + + const pluginService = container.get(K8sPluginServiceImpl); + await pluginService.deferred.promise; + + mockGet.mockImplementation(async (url: string): Promise => { + if (url === 'http://internal.uri/v3/plugins/bierner/markdown-mermaid/latest/che-theia-plugin.yaml') { + return MARKDOWN_MERMAID_CHE_THEIA_PLUGIN_YAML; + } + + if ( + url === + 'http://internal.uri/v3/plugins/goddard/mermaid-markdown-syntax-highlighting/latest/che-theia-plugin.yaml' + ) { + return MERMAID_MARKDOWN_SYNTAX_HIGHLIGHTING_CHE_THEIA_PLUGIN_YAML; + } + + return undefined; + }); + + mockHead.mockImplementation( + async (url: string): Promise => + url === 'https://somehost/vscode-markdown-mermaid-1.9.2.vsix' || + url === 'https://somehost/vscode-mermaid-syntax-highlight.vsix' + ); + + let askedDependencies; + + const askToInstallDependenciesMock = jest.fn(); + askToInstallDependenciesMock.mockImplementation(async (dependencies: PluginDependencies): Promise => { + askedDependencies = dependencies.plugins; + return false; + }); + + pluginService.setClient({ + notifyPluginCacheSizeChanged: jest.fn(), + notifyPluginCached: jest.fn(), + notifyCachingComplete: jest.fn(), + invalidRegistryFound: jest.fn(), + invalidPluginFound: jest.fn(), + askToInstallDependencies: askToInstallDependenciesMock, + }); + + const getPluginsMock = jest.spyOn(pluginService, 'getPlugins'); + getPluginsMock.mockImplementation( + async (): Promise => [ + { + publisher: 'goddard', + name: 'mermaid-markdown-syntax-highlighting', + version: 'latest', + } as ChePluginMetadata, + ] + ); + + const result = await pluginService.installPlugin('bierner/markdown-mermaid/latest'); + expect(result).toBe(false); + + expect(askedDependencies).toEqual(['goddard/mermaid-markdown-syntax-highlighting/latest']); + + expect(pluginService.toInstall).toEqual([]); + }); + + test('install plugin :: should NOT ask the user if the required dependency is already installed', async () => { + process.env.CHE_PLUGIN_REGISTRY_URL = 'http://public.uri/v3/'; + process.env.CHE_PLUGIN_REGISTRY_INTERNAL_URL = 'http://internal.uri/v3/'; + + Object.assign(fs, { + pathExists: async (path: string): Promise => { + if (path === '/projects' || path === CHE_PLUGINS_JSON) { + return true; + } + + return false; + }, + + readFile: async (path: string): Promise => { + if (path === CHE_PLUGINS_JSON) { + return EMPTY_PLUGINS_JSON; + } + + return Promise.reject(); + }, + }); + + const pluginService = container.get(K8sPluginServiceImpl); + await pluginService.deferred.promise; + + // add the required dependency to list of installed plugins + pluginService.installedPlugins.push('goddard/mermaid-markdown-syntax-highlighting/latest'); + + mockGet.mockImplementation(async (url: string): Promise => { + if (url === 'http://internal.uri/v3/plugins/bierner/markdown-mermaid/latest/che-theia-plugin.yaml') { + return MARKDOWN_MERMAID_CHE_THEIA_PLUGIN_YAML; + } + + return undefined; + }); + + mockHead.mockImplementation( + async (url: string): Promise => + url === 'https://somehost/vscode-markdown-mermaid-1.9.2.vsix' || + url === 'https://somehost/vscode-mermaid-syntax-highlight.vsix' + ); + + const askToInstallDependenciesMock = jest.fn(); + + pluginService.setClient({ + notifyPluginCacheSizeChanged: jest.fn(), + notifyPluginCached: jest.fn(), + notifyCachingComplete: jest.fn(), + invalidRegistryFound: jest.fn(), + invalidPluginFound: jest.fn(), + askToInstallDependencies: askToInstallDependenciesMock, + }); + + const getPluginsMock = jest.spyOn(pluginService, 'getPlugins'); + getPluginsMock.mockImplementation( + async (): Promise => [ + { + publisher: 'goddard', + name: 'mermaid-markdown-syntax-highlighting', + version: 'latest', + } as ChePluginMetadata, + { + publisher: 'bierner', + name: 'markdown-mermaid', + version: 'latest', + } as ChePluginMetadata, + ] + ); + + // install plugin + const result = await pluginService.installPlugin('bierner/markdown-mermaid/latest'); + expect(result).toBe(true); + + expect(askToInstallDependenciesMock).toHaveBeenCalledTimes(0); + + expect(pluginService.toInstall).toEqual(['bierner/markdown-mermaid/latest']); + + // list of installed plugins should not be changed + expect(pluginService.installedPlugins).toEqual(['goddard/mermaid-markdown-syntax-highlighting/latest']); + }); + + test('install plugin :: should handle chain of dependencies', async () => { + process.env.CHE_PLUGIN_REGISTRY_URL = 'http://public.uri/v3/'; + process.env.CHE_PLUGIN_REGISTRY_INTERNAL_URL = 'http://internal.uri/v3/'; + + Object.assign(fs, { + pathExists: async (path: string): Promise => { + if (path === '/projects' || path === CHE_PLUGINS_JSON) { + return true; + } + + return false; + }, + + readFile: async (path: string): Promise => { + if (path === CHE_PLUGINS_JSON) { + return EMPTY_PLUGINS_JSON; + } + + return Promise.reject(); + }, + }); + + const pluginService = container.get(K8sPluginServiceImpl); + await pluginService.deferred.promise; + + mockGet.mockImplementation(async (url: string): Promise => { + switch (url) { + case 'http://internal.uri/v3/plugins/ex/markdown-mermaid-templates/latest/che-theia-plugin.yaml': + return MARKDOWN_MERMAID_TEMPLATES_CHE_THEIA_PLUGIN_YAML; + + case 'http://internal.uri/v3/plugins/bierner/markdown-mermaid/latest/che-theia-plugin.yaml': + return MARKDOWN_MERMAID_CHE_THEIA_PLUGIN_YAML; + + case 'http://internal.uri/v3/plugins/goddard/mermaid-markdown-syntax-highlighting/latest/che-theia-plugin.yaml': + return MERMAID_MARKDOWN_SYNTAX_HIGHLIGHTING_CHE_THEIA_PLUGIN_YAML; + } + + return undefined; + }); + + mockHead.mockImplementation( + async (url: string): Promise => + url === 'https://somehost/vscode-markdown-mermaid-1.9.2.vsix' || + url === 'https://somehost/vscode-mermaid-syntax-highlight.vsix' || + url === 'https://somehost/vscode-markdown-mermaid-templates-0.6.1.vsix' + ); + + let askedDependencies; + + const askToInstallDependenciesMock = jest.fn(); + askToInstallDependenciesMock.mockImplementation(async (dependencies: PluginDependencies): Promise => { + askedDependencies = dependencies.plugins; + return true; + }); + + pluginService.setClient({ + notifyPluginCacheSizeChanged: jest.fn(), + notifyPluginCached: jest.fn(), + notifyCachingComplete: jest.fn(), + invalidRegistryFound: jest.fn(), + invalidPluginFound: jest.fn(), + askToInstallDependencies: askToInstallDependenciesMock, + }); + + const getPluginsMock = jest.spyOn(pluginService, 'getPlugins'); + getPluginsMock.mockImplementation( + async (): Promise => [ + { + publisher: 'goddard', + name: 'mermaid-markdown-syntax-highlighting', + version: 'latest', + } as ChePluginMetadata, + { + publisher: 'bierner', + name: 'markdown-mermaid', + version: 'latest', + } as ChePluginMetadata, + ] + ); + + // install plugin + const result = await pluginService.installPlugin('ex/markdown-mermaid-templates/latest'); + expect(result).toBe(true); + + expect(askedDependencies).toEqual([ + 'bierner/markdown-mermaid/latest', + 'goddard/mermaid-markdown-syntax-highlighting/latest', + ]); + + expect(pluginService.toInstall).toEqual([ + 'ex/markdown-mermaid-templates/latest', + 'bierner/markdown-mermaid/latest', + 'goddard/mermaid-markdown-syntax-highlighting/latest', + ]); + }); + + test('install plugin :: should ask to install only ONE dependent plugin (another one is not listed in plugin registry index)', async () => { + process.env.CHE_PLUGIN_REGISTRY_URL = 'http://public.uri/v3/'; + process.env.CHE_PLUGIN_REGISTRY_INTERNAL_URL = 'http://internal.uri/v3/'; + + Object.assign(fs, { + pathExists: async (path: string): Promise => { + if (path === '/projects' || path === CHE_PLUGINS_JSON) { + return true; + } + + return false; + }, + + readFile: async (path: string): Promise => { + if (path === CHE_PLUGINS_JSON) { + return EMPTY_PLUGINS_JSON; + } + + return Promise.reject(); + }, + }); + + const pluginService = container.get(K8sPluginServiceImpl); + await pluginService.deferred.promise; + + mockGet.mockImplementation(async (url: string): Promise => { + switch (url) { + case 'http://internal.uri/v3/plugins/ex/markdown-mermaid-templates/latest/che-theia-plugin.yaml': + return MARKDOWN_MERMAID_TEMPLATES_CHE_THEIA_PLUGIN_YAML; + + case 'http://internal.uri/v3/plugins/bierner/markdown-mermaid/latest/che-theia-plugin.yaml': + return MARKDOWN_MERMAID_CHE_THEIA_PLUGIN_YAML; + + case 'http://internal.uri/v3/plugins/goddard/mermaid-markdown-syntax-highlighting/latest/che-theia-plugin.yaml': + return MERMAID_MARKDOWN_SYNTAX_HIGHLIGHTING_CHE_THEIA_PLUGIN_YAML; + } + + return undefined; + }); + + mockHead.mockImplementation( + async (url: string): Promise => + url === 'https://somehost/vscode-markdown-mermaid-1.9.2.vsix' || + url === 'https://somehost/vscode-mermaid-syntax-highlight.vsix' || + url === 'https://somehost/vscode-markdown-mermaid-templates-0.6.1.vsix' + ); + + let askedDependencies; + + const askToInstallDependenciesMock = jest.fn(); + askToInstallDependenciesMock.mockImplementation(async (dependencies: PluginDependencies): Promise => { + askedDependencies = dependencies.plugins; + return true; + }); + + pluginService.setClient({ + notifyPluginCacheSizeChanged: jest.fn(), + notifyPluginCached: jest.fn(), + notifyCachingComplete: jest.fn(), + invalidRegistryFound: jest.fn(), + invalidPluginFound: jest.fn(), + askToInstallDependencies: askToInstallDependenciesMock, + }); + + const getPluginsMock = jest.spyOn(pluginService, 'getPlugins'); + getPluginsMock.mockImplementation( + async (): Promise => [ + { + publisher: 'bierner', + name: 'markdown-mermaid', + version: 'latest', + } as ChePluginMetadata, + ] + ); + + // install plugin + const result = await pluginService.installPlugin('ex/markdown-mermaid-templates/latest'); + expect(result).toBe(true); + + expect(askedDependencies).toEqual(['bierner/markdown-mermaid/latest']); + expect(askToInstallDependenciesMock).toHaveBeenCalledTimes(1); + + expect(pluginService.toInstall).toEqual([ + 'ex/markdown-mermaid-templates/latest', + 'bierner/markdown-mermaid/latest', + 'goddard/mermaid-markdown-syntax-highlighting/latest', + ]); + }); + + test('remove plugin :: must do nothing if the plugin is missing', async () => { + Object.assign(fs, { + pathExists: async (path: string): Promise => { + if (path === '/projects' || path === CHE_PLUGINS_JSON) { + return true; + } + + return false; + }, + + readFile: async (path: string): Promise => { + if (path === CHE_PLUGINS_JSON) { + return EMPTY_PLUGINS_JSON; + } + + return Promise.reject(); + }, + }); + + const pluginService = container.get(K8sPluginServiceImpl); + await pluginService.deferred.promise; + + await pluginService.removePlugin('goddard/mermaid-markdown-syntax-highlighting/latest'); + + expect(pluginService.installedPlugins).toEqual([]); + expect(pluginService.toInstall).toEqual([]); + expect(pluginService.toRemove).toEqual([]); + }); + + test('remove plugin :: must do nothing if the plugin is already marked for removal', async () => { + Object.assign(fs, { + pathExists: async (path: string): Promise => { + if (path === '/projects' || path === CHE_PLUGINS_JSON) { + return true; + } + + return false; + }, + + readFile: async (path: string): Promise => { + if (path === CHE_PLUGINS_JSON) { + return PLUGINS_JSON_WITH_MERMAID_MARKDOWN_SYNTAX_HIGHLIGHTING_PLUGIN; + } + + return Promise.reject(); + }, + }); + + const pluginService = container.get(K8sPluginServiceImpl); + await pluginService.deferred.promise; + + pluginService.toRemove.push('goddard/mermaid-markdown-syntax-highlighting/latest'); + + await pluginService.removePlugin('goddard/mermaid-markdown-syntax-highlighting/latest'); + + expect(pluginService.installedPlugins).toEqual(['goddard/mermaid-markdown-syntax-highlighting/latest']); + expect(pluginService.toInstall).toEqual([]); + expect(pluginService.toRemove).toEqual(['goddard/mermaid-markdown-syntax-highlighting/latest']); + }); + + test('remove plugin :: must cancel the installation if the plugin is going to be intalled', async () => { + Object.assign(fs, { + pathExists: async (path: string): Promise => { + if (path === '/projects' || path === CHE_PLUGINS_JSON) { + return true; + } + + return false; + }, + + readFile: async (path: string): Promise => { + if (path === CHE_PLUGINS_JSON) { + return EMPTY_PLUGINS_JSON; + } + + return Promise.reject(); + }, + }); + + const pluginService = container.get(K8sPluginServiceImpl); + await pluginService.deferred.promise; + + pluginService.toInstall.push('goddard/mermaid-markdown-syntax-highlighting/latest'); + + await pluginService.removePlugin('goddard/mermaid-markdown-syntax-highlighting/latest'); + + expect(pluginService.installedPlugins).toEqual([]); + expect(pluginService.toInstall).toEqual([]); + expect(pluginService.toRemove).toEqual([]); + }); + + test('remove plugin :: must cancel the removal if there are several plugin dependencies', async () => { + process.env.CHE_PLUGIN_REGISTRY_URL = 'http://public.uri/v3/'; + process.env.CHE_PLUGIN_REGISTRY_INTERNAL_URL = 'http://internal.uri/v3/'; + + Object.assign(fs, { + pathExists: async (path: string): Promise => { + if (path === '/projects' || path === CHE_PLUGINS_JSON) { + return true; + } + + return false; + }, + + readFile: async (path: string): Promise => { + if (path === CHE_PLUGINS_JSON) { + return EMPTY_PLUGINS_JSON; + } + + return Promise.reject(); + }, + }); + + const pluginService = container.get(K8sPluginServiceImpl); + await pluginService.deferred.promise; + + mockGet.mockImplementation(async (url: string): Promise => { + if (url === 'http://internal.uri/v3/plugins/bierner/markdown-mermaid/latest/che-theia-plugin.yaml') { + return MARKDOWN_MERMAID_CHE_THEIA_PLUGIN_YAML; + } + + if (url === 'http://internal.uri/v3/plugins/ex/markdown-mermaid-templates/latest/che-theia-plugin.yaml') { + return MARKDOWN_MERMAID_TEMPLATES_CHE_THEIA_PLUGIN_YAML; + } + + return undefined; + }); + + pluginService.installedPlugins.push('goddard/mermaid-markdown-syntax-highlighting/latest'); + pluginService.installedPlugins.push('bierner/markdown-mermaid/latest'); + + pluginService.toInstall.push('ex/markdown-mermaid-templates/latest'); + + const getPluginsMock = jest.spyOn(pluginService, 'getPlugins'); + getPluginsMock.mockImplementation( + async (): Promise => [ + { + publisher: 'goddard', + name: 'mermaid-markdown-syntax-highlighting', + version: 'latest', + } as ChePluginMetadata, + { + publisher: 'bierner', + name: 'markdown-mermaid', + version: 'latest', + } as ChePluginMetadata, + { + publisher: 'ex', + name: 'markdown-mermaid-templates', + version: 'latest', + } as ChePluginMetadata, + ] + ); + + try { + await pluginService.removePlugin('goddard/mermaid-markdown-syntax-highlighting/latest'); + } catch (error) { + expect(error.message).toBe( + "Cannot remove 'goddard/mermaid-markdown-syntax-highlighting/latest'. Plugins 'bierner/markdown-mermaid/latest', 'ex/markdown-mermaid-templates/latest' depend on this." + ); + + expect(pluginService.installedPlugins).toEqual([ + 'goddard/mermaid-markdown-syntax-highlighting/latest', + 'bierner/markdown-mermaid/latest', + ]); + expect(pluginService.toInstall).toEqual(['ex/markdown-mermaid-templates/latest']); + expect(pluginService.toRemove).toEqual([]); + + return; + } + + fail(); + }); + + test('remove plugin :: must reject the removal of the plugin if it is required for successful installation', async () => { + process.env.CHE_PLUGIN_REGISTRY_URL = 'http://public.uri/v3/'; + process.env.CHE_PLUGIN_REGISTRY_INTERNAL_URL = 'http://internal.uri/v3/'; + + Object.assign(fs, { + pathExists: async (path: string): Promise => { + if (path === '/projects' || path === CHE_PLUGINS_JSON) { + return true; + } + + return false; + }, + + readFile: async (path: string): Promise => { + if (path === CHE_PLUGINS_JSON) { + return EMPTY_PLUGINS_JSON; + } + + return Promise.reject(); + }, + }); + + const pluginService = container.get(K8sPluginServiceImpl); + await pluginService.deferred.promise; + + mockGet.mockImplementation(async (url: string): Promise => { + if (url === 'http://internal.uri/v3/plugins/bierner/markdown-mermaid/latest/che-theia-plugin.yaml') { + return MARKDOWN_MERMAID_CHE_THEIA_PLUGIN_YAML; + } + + if ( + url === + 'http://internal.uri/v3/plugins/goddard/mermaid-markdown-syntax-highlighting/latest/che-theia-plugin.yaml' + ) { + return MERMAID_MARKDOWN_SYNTAX_HIGHLIGHTING_CHE_THEIA_PLUGIN_YAML; + } + + return undefined; + }); + + pluginService.toInstall.push('bierner/markdown-mermaid/latest'); + pluginService.toInstall.push('goddard/mermaid-markdown-syntax-highlighting/latest'); + + const getPluginsMock = jest.spyOn(pluginService, 'getPlugins'); + getPluginsMock.mockImplementation( + async (): Promise => [ + { + publisher: 'goddard', + name: 'mermaid-markdown-syntax-highlighting', + version: 'latest', + } as ChePluginMetadata, + { + publisher: 'bierner', + name: 'markdown-mermaid', + version: 'latest', + } as ChePluginMetadata, + ] + ); + + try { + await pluginService.removePlugin('goddard/mermaid-markdown-syntax-highlighting/latest'); + } catch (error) { + expect(error.message).toBe( + "Cannot remove 'goddard/mermaid-markdown-syntax-highlighting/latest'. Plugin 'bierner/markdown-mermaid/latest' depends on this." + ); + + expect(pluginService.toInstall).toEqual([ + 'bierner/markdown-mermaid/latest', + 'goddard/mermaid-markdown-syntax-highlighting/latest', + ]); + + expect(pluginService.installedPlugins).toEqual([]); + expect(pluginService.toRemove).toEqual([]); + + return; + } + + fail(); + }); + + test('remove plugin :: must autoremove dependent plugin if it is not present in plugin registry index', async () => { + process.env.CHE_PLUGIN_REGISTRY_URL = 'http://public.uri/v3/'; + process.env.CHE_PLUGIN_REGISTRY_INTERNAL_URL = 'http://internal.uri/v3/'; + + Object.assign(fs, { + pathExists: async (path: string): Promise => { + if (path === '/projects' || path === CHE_PLUGINS_JSON) { + return true; + } + + return false; + }, + + readFile: async (path: string): Promise => { + if (path === CHE_PLUGINS_JSON) { + return PLUGINS_JSON_WITH_THREE_JAVA_PLUGINS; + } + + return Promise.reject(); + }, + }); + + const pluginService = container.get(K8sPluginServiceImpl); + await pluginService.deferred.promise; + + mockGet.mockImplementation(async (url: string): Promise => { + if (url === 'http://internal.uri/v3/plugins/redhat/java/latest/che-theia-plugin.yaml') { + return JAVA_CHE_THEIA_PLUGIN_YAML; + } + + if (url === 'http://internal.uri/v3/plugins/vscjava/vscode-java-debug/latest/che-theia-plugin.yaml') { + return VSCODE_JAVA_DEBUG_CHE_THEIA_PLUGIN; + } + + if (url === 'http://internal.uri/v3/plugins/vscjava/vscode-java-test/latest/che-theia-plugin.yaml') { + return VSCODE_JAVA_TEST_CHE_THEIA_PLUGIN; + } + + return undefined; + }); + + const getPluginsMock = jest.spyOn(pluginService, 'getPlugins'); + getPluginsMock.mockImplementation( + async (): Promise => [ + { + publisher: 'redhat', + name: 'java', + version: 'latest', + } as ChePluginMetadata, + ] + ); + + await pluginService.removePlugin('redhat/java/latest'); + expect(pluginService.toRemove).toEqual(['redhat/java/latest', 'vscjava/vscode-java-test/latest']); + }); + + test('persist :: do nothing if there is nothing to do', async () => { + process.env.CHE_PLUGIN_REGISTRY_URL = 'http://public.uri/v3/'; + process.env.CHE_PLUGIN_REGISTRY_INTERNAL_URL = 'http://internal.uri/v3/'; + + Object.assign(fs, { + pathExists: async (path: string): Promise => { + if (path === '/projects' || path === CHE_PLUGINS_JSON) { + return true; + } + + return false; + }, + + readFile: async (path: string): Promise => { + if (path === CHE_PLUGINS_JSON) { + return EMPTY_PLUGINS_JSON; + } + + return Promise.reject(); + }, + }); + + const pluginService = container.get(K8sPluginServiceImpl); + await pluginService.deferred.promise; + + pluginService.installedPlugins.push('bierner/markdown-mermaid/latest'); + pluginService.installedPlugins.push('goddard/mermaid-markdown-syntax-highlighting/latest'); + + const fetchCheTheiaPluginYamlMock = jest.spyOn(pluginService, 'fetchCheTheiaPluginYaml'); + + await pluginService.persist(); + + expect(pluginService.installedPlugins).toEqual([ + 'bierner/markdown-mermaid/latest', + 'goddard/mermaid-markdown-syntax-highlighting/latest', + ]); + expect(pluginService.toInstall).toEqual([]); + expect(pluginService.toRemove).toEqual([]); + + expect(fetchCheTheiaPluginYamlMock).toHaveBeenCalledTimes(0); + }); + + test('persist :: must download two extensions into /plugins directory without patching devworkspace object', async () => { + process.env.CHE_PLUGIN_REGISTRY_URL = 'http://public.uri/v3/'; + process.env.CHE_PLUGIN_REGISTRY_INTERNAL_URL = 'http://internal.uri/v3/'; + + Object.assign(fs, { + pathExists: async (path: string): Promise => { + if (path === '/projects' || path === CHE_PLUGINS_JSON) { + return true; + } + + return false; + }, + + readFile: async (path: string): Promise => { + if (path === CHE_PLUGINS_JSON) { + return EMPTY_PLUGINS_JSON; + } + + return Promise.reject(); + }, + }); + + const pluginService = container.get(K8sPluginServiceImpl); + await pluginService.deferred.promise; + + pluginService.toInstall.push('bierner/markdown-mermaid/latest'); + pluginService.toInstall.push('goddard/mermaid-markdown-syntax-highlighting/latest'); + + const askedPluginYamls: string[] = []; + const askedExtensions: string[] = []; + + mockGet.mockImplementation(async (url: string): Promise => { + switch (url) { + case 'http://internal.uri/v3/plugins/bierner/markdown-mermaid/latest/che-theia-plugin.yaml': + askedPluginYamls.push('http://internal.uri/v3/plugins/bierner/markdown-mermaid/latest/che-theia-plugin.yaml'); + return MARKDOWN_MERMAID_CHE_THEIA_PLUGIN_YAML; + + case 'http://internal.uri/v3/plugins/goddard/mermaid-markdown-syntax-highlighting/latest/che-theia-plugin.yaml': + askedPluginYamls.push( + 'http://internal.uri/v3/plugins/goddard/mermaid-markdown-syntax-highlighting/latest/che-theia-plugin.yaml' + ); + return MERMAID_MARKDOWN_SYNTAX_HIGHLIGHTING_CHE_THEIA_PLUGIN_YAML; + + case 'https://somehost/vscode-markdown-mermaid-1.9.2.vsix': + askedExtensions.push('https://somehost/vscode-markdown-mermaid-1.9.2.vsix'); + return 'content of vscode-markdown-mermaid-1.9.2.vsix'; + + case 'https://somehost/vscode-mermaid-syntax-highlight.vsix': + askedExtensions.push('https://somehost/vscode-mermaid-syntax-highlight.vsix'); + return 'content of vscode-mermaid-syntax-highlight.vsix'; + } + + return undefined; + }); + + let vscodeMarkdownMermaidContent; + let vscodeMermaidSyntaxHighlightContent; + + let chePLuginsJsonContent = ''; + + const writeFileMock = jest.fn(); + writeFileMock.mockImplementation(async (path: string, data: string): Promise => { + if (path === '/plugins/vscode-markdown-mermaid-1.9.2.vsix') { + vscodeMarkdownMermaidContent = data; + return; + } + + if (path === '/plugins/vscode-mermaid-syntax-highlight.vsix') { + vscodeMermaidSyntaxHighlightContent = data; + return; + } + + if (path === '/plugins/che-plugins.json') { + chePLuginsJsonContent = data; + return; + } + + throw new Error(`Failure to write ${path}`); + }); + + Object.assign(fs, { + writeFile: writeFileMock, + }); + + const fetchCheTheiaPluginYamlMock = jest.spyOn(pluginService, 'fetchCheTheiaPluginYaml'); + const downloadExtensionsMock = jest.spyOn(pluginService, 'downloadExtensions'); + + const devWorkspaceTemplate: Devfile = JSON.parse(devWorkspaceTemplateContent) as Devfile; + const getDevfileMock = jest.spyOn(devfileService, 'get'); + getDevfileMock.mockImplementation(async () => devWorkspaceTemplate); + + const patchDevfileMock = jest.spyOn(devfileService, 'patch'); + patchDevfileMock.mockImplementation(async (): Promise => {}); + + const stopWorkspaceMock = jest.spyOn(workspaceService, 'stop'); + stopWorkspaceMock.mockImplementation(async (): Promise => {}); + + await pluginService.persist(); + + expect(fetchCheTheiaPluginYamlMock).toHaveBeenCalledTimes(2); + expect(downloadExtensionsMock).toHaveBeenCalled(); + + expect(patchDevfileMock).toHaveBeenCalledTimes(0); + expect(stopWorkspaceMock).toHaveBeenCalledTimes(1); + + expect(writeFileMock).toHaveBeenCalledTimes(3); + + expect(askedPluginYamls).toEqual([ + 'http://internal.uri/v3/plugins/bierner/markdown-mermaid/latest/che-theia-plugin.yaml', + 'http://internal.uri/v3/plugins/goddard/mermaid-markdown-syntax-highlighting/latest/che-theia-plugin.yaml', + ]); + + expect(askedExtensions).toEqual([ + 'https://somehost/vscode-markdown-mermaid-1.9.2.vsix', + 'https://somehost/vscode-mermaid-syntax-highlight.vsix', + ]); + + expect(vscodeMarkdownMermaidContent).toBe('content of vscode-markdown-mermaid-1.9.2.vsix'); + expect(vscodeMermaidSyntaxHighlightContent).toBe('content of vscode-mermaid-syntax-highlight.vsix'); + + expect(JSON.parse(chePLuginsJsonContent)).toEqual( + JSON.parse(` + { + "plugins": [ + "bierner.markdown-mermaid", + "goddard.mermaid-markdown-syntax-highlighting" + ] + } + `) + ); + }); + + test('persist :: must download three extensions into /plugins/sidecars/tools directory and update devworkspace object', async () => { + process.env.CHE_PLUGIN_REGISTRY_URL = 'http://public.uri/v3/'; + process.env.CHE_PLUGIN_REGISTRY_INTERNAL_URL = 'http://internal.uri/v3/'; + + Object.assign(fs, { + pathExists: async (path: string): Promise => { + if (path === '/projects' || path === CHE_PLUGINS_JSON) { + return true; + } + + return false; + }, + + readFile: async (path: string): Promise => { + if (path === CHE_PLUGINS_JSON) { + return EMPTY_PLUGINS_JSON; + } + + return Promise.reject(); + }, + }); + + const pluginService = container.get(K8sPluginServiceImpl); + await pluginService.deferred.promise; + + pluginService.toInstall.push('redhat/java/latest'); + pluginService.toInstall.push('vscjava/vscode-java-debug/latest'); + pluginService.toInstall.push('vscjava/vscode-java-test/latest'); + + const askedPluginYamls: string[] = []; + const askedExtensions: string[] = []; + + mockGet.mockImplementation(async (url: string): Promise => { + switch (url) { + case 'http://internal.uri/v3/plugins/redhat/java/latest/che-theia-plugin.yaml': + askedPluginYamls.push('http://internal.uri/v3/plugins/redhat/java/latest/che-theia-plugin.yaml'); + return JAVA_CHE_THEIA_PLUGIN_YAML; + + case 'http://internal.uri/v3/plugins/vscjava/vscode-java-debug/latest/che-theia-plugin.yaml': + askedPluginYamls.push( + 'http://internal.uri/v3/plugins/vscjava/vscode-java-debug/latest/che-theia-plugin.yaml' + ); + return VSCODE_JAVA_DEBUG_CHE_THEIA_PLUGIN; + + case 'http://internal.uri/v3/plugins/vscjava/vscode-java-test/latest/che-theia-plugin.yaml': + askedPluginYamls.push('http://internal.uri/v3/plugins/vscjava/vscode-java-test/latest/che-theia-plugin.yaml'); + return VSCODE_JAVA_TEST_CHE_THEIA_PLUGIN; + + case 'https://download.jboss.org/jbosstools/static/jdt.ls/stable/java-0.82.0-369.vsix': + askedExtensions.push('https://download.jboss.org/jbosstools/static/jdt.ls/stable/java-0.82.0-369.vsix'); + return 'content of java-0.82.0-369.vsix'; + + case 'https://download.jboss.org/jbosstools/vscode/3rdparty/vscode-java-debug/vscode-java-debug-0.26.0.vsix': + askedExtensions.push( + 'https://download.jboss.org/jbosstools/vscode/3rdparty/vscode-java-debug/vscode-java-debug-0.26.0.vsix' + ); + return 'content of vscode-java-debug-0.26.0.vsix'; + + case 'https://open-vsx.org/api/vscjava/vscode-java-test/0.28.1/file/vscjava.vscode-java-test-0.28.1.vsix': + askedExtensions.push( + 'https://open-vsx.org/api/vscjava/vscode-java-test/0.28.1/file/vscjava.vscode-java-test-0.28.1.vsix' + ); + return 'content of vscjava.vscode-java-test-0.28.1.vsix'; + } + + return undefined; + }); + + const devWorkspaceTemplate: Devfile = JSON.parse(devWorkspaceTemplateContent) as Devfile; + const getDevfileMock = jest.spyOn(devfileService, 'get'); + getDevfileMock.mockImplementation(async () => devWorkspaceTemplate); + + let patchPath; + let patchValue; + + const patchDevfileMock = jest.spyOn(devfileService, 'patch'); + patchDevfileMock.mockImplementation(async (path, value): Promise => { + patchPath = path; + patchValue = value; + }); + + let javaExtensionContent; + let javaTestExtensionContent; + let javaDebugExtensionContent; + + let chePLuginsJsonContent = ''; + + Object.assign(fs, { + ensureDir: async (path: string) => {}, + writeFile: async (path: string, data: string): Promise => { + if (path === '/plugins/sidecars/tools/java-0.82.0-369.vsix') { + javaExtensionContent = data; + return; + } + + if (path === '/plugins/sidecars/tools/vscjava.vscode-java-test-0.28.1.vsix') { + javaTestExtensionContent = data; + return; + } + + if (path === '/plugins/sidecars/tools/vscode-java-debug-0.26.0.vsix') { + javaDebugExtensionContent = data; + return; + } + + if (path === '/plugins/che-plugins.json') { + chePLuginsJsonContent = data; + return; + } + + throw new Error(`Failure to write ${path}`); + }, + }); + + const stopWorkspaceMock = jest.spyOn(workspaceService, 'stop'); + stopWorkspaceMock.mockImplementation(async (): Promise => {}); + + await pluginService.persist(); + + expect(askedPluginYamls).toEqual([ + 'http://internal.uri/v3/plugins/redhat/java/latest/che-theia-plugin.yaml', + 'http://internal.uri/v3/plugins/vscjava/vscode-java-debug/latest/che-theia-plugin.yaml', + 'http://internal.uri/v3/plugins/vscjava/vscode-java-test/latest/che-theia-plugin.yaml', + ]); + + expect(askedExtensions).toEqual([ + 'https://download.jboss.org/jbosstools/static/jdt.ls/stable/java-0.82.0-369.vsix', + 'https://download.jboss.org/jbosstools/vscode/3rdparty/vscode-java-debug/vscode-java-debug-0.26.0.vsix', + 'https://open-vsx.org/api/vscjava/vscode-java-test/0.28.1/file/vscjava.vscode-java-test-0.28.1.vsix', + ]); + + expect(patchDevfileMock).toHaveBeenCalled(); + expect(stopWorkspaceMock).toHaveBeenCalled(); + + expect(patchPath).toBe('/spec/template/components'); + + const toolsContainer = pluginService.findDevContainer({ + components: patchValue, + } as Devfile); + + expect(toolsContainer).toBeDefined(); + + expect(toolsContainer.name).toBe('tools'); + + expect(toolsContainer.attributes).toBeDefined(); + + const extensionsAttribute = toolsContainer.attributes!['che-theia.eclipse.org/vscode-extensions']; + expect(extensionsAttribute).toBeDefined(); + + const preferencesAttribute = toolsContainer.attributes!['che-theia.eclipse.org/vscode-preferences']; + expect(preferencesAttribute).toBeDefined(); + + expect(extensionsAttribute).toEqual([ + 'https://download.jboss.org/jbosstools/static/jdt.ls/stable/java-0.82.0-369.vsix', + 'https://download.jboss.org/jbosstools/vscode/3rdparty/vscode-java-debug/vscode-java-debug-0.26.0.vsix', + 'https://open-vsx.org/api/vscjava/vscode-java-test/0.28.1/file/vscjava.vscode-java-test-0.28.1.vsix', + ]); + + expect(preferencesAttribute).toEqual({ + 'java.server.launchMode': 'Standard', + }); + + expect(javaExtensionContent).toBe('content of java-0.82.0-369.vsix'); + expect(javaTestExtensionContent).toBe('content of vscjava.vscode-java-test-0.28.1.vsix'); + expect(javaDebugExtensionContent).toBe('content of vscode-java-debug-0.26.0.vsix'); + + expect(JSON.parse(chePLuginsJsonContent)).toEqual( + JSON.parse(` + { + "plugins": [ + "redhat.java", + "vscjava.vscode-java-debug", + "vscjava.vscode-java-test" + ] + } + `) + ); + }); +}); diff --git a/extensions/eclipse-che-theia-workspace/package.json b/extensions/eclipse-che-theia-workspace/package.json index a5943791a..6604fde86 100644 --- a/extensions/eclipse-che-theia-workspace/package.json +++ b/extensions/eclipse-che-theia-workspace/package.json @@ -18,7 +18,8 @@ "@eclipse-che/api": "latest", "@theia/workspace": "next", "@eclipse-che/theia-remote-api": "^0.0.1", - "js-yaml": "3.13.1" + "js-yaml": "3.13.1", + "react": "^16.8.0" }, "devDependencies": { "@types/js-yaml": "3.11.2",