diff --git a/package.json b/package.json index 80f450d9d..5324abe78 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "start": "${PWD}/local-start.sh $@", "license:check": "docker run --rm -t -v ${PWD}/:/workspace/project quay.io/che-incubator/dash-licenses:next --check", "license:generate": "docker run --rm -t -v ${PWD}/:/workspace/project quay.io/che-incubator/dash-licenses:next", - "test": "lerna run test --stream -- $@", + "test": "lerna run test --stream -- --no-cache $@", "pretest": "yarn run prebuild", "test:coverage": "yarn run test -- --runInBand --coverage", "format:check": "yarn workspaces run format:check", diff --git a/packages/dashboard-frontend/jest.config.js b/packages/dashboard-frontend/jest.config.js index 94092f1da..654be7bdf 100644 --- a/packages/dashboard-frontend/jest.config.js +++ b/packages/dashboard-frontend/jest.config.js @@ -21,13 +21,14 @@ module.exports = { '\\.(css|less|sass|scss|styl)$': '/__mocks__/styleMock.js', '\\.(gif|ttf|eot|svg)$': '/__mocks__/fileMock.js', 'monaco-editor-core': 'monaco-editor-core/esm/vs/editor/editor.main', - 'vscode-languageserver-protocol/lib/utils/is': 'vscode-languageserver-protocol/lib/common/utils/is', + 'vscode-languageserver-protocol/lib/utils/is': + 'vscode-languageserver-protocol/lib/common/utils/is', 'vscode-languageserver-protocol/lib/main': 'vscode-languageserver-protocol/lib/node/main', }, globals: { 'ts-jest': { tsconfig: 'tsconfig.test.json', - } + }, }, setupFilesAfterEnv: ['./jest.setup.ts'], setupFiles: ['./src/inversify.config.ts'], @@ -47,6 +48,6 @@ module.exports = { branches: 36, functions: 50, lines: 50, - } + }, }, -} +}; diff --git a/packages/dashboard-frontend/src/containers/FactoryLoader/index.tsx b/packages/dashboard-frontend/src/containers/FactoryLoader/index.tsx index a551e8a1e..5ba87b08a 100644 --- a/packages/dashboard-frontend/src/containers/FactoryLoader/index.tsx +++ b/packages/dashboard-frontend/src/containers/FactoryLoader/index.tsx @@ -11,6 +11,7 @@ */ import { AlertActionLink, AlertVariant } from '@patternfly/react-core'; +import axios from 'axios'; import React from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { History } from 'history'; @@ -18,7 +19,8 @@ import common from '@eclipse-che/common'; import { delay } from '../../services/helpers/delay'; import { AppState } from '../../store'; import * as FactoryResolverStore from '../../store/FactoryResolver'; -import * as WorkspaceStore from '../../store/Workspaces'; +import * as WorkspacesStore from '../../store/Workspaces'; +import * as DevWorkspacesStore from '../../store/Workspaces/devWorkspaces'; import FactoryLoader from '../../pages/FactoryLoader'; import { selectAllWorkspaces, @@ -41,7 +43,7 @@ import { selectDefaultNamespace, selectInfrastructureNamespaces, } from '../../store/InfrastructureNamespaces/selectors'; -import { safeLoad } from 'js-yaml'; +import { safeLoad, safeLoadAll } from 'js-yaml'; import updateDevfileMetadata, { FactorySource } from './updateDevfileMetadata'; import { DEVWORKSPACE_DEVFILE_SOURCE } from '../../services/workspace-client/devworkspace/devWorkspaceClient'; import devfileApi from '../../services/devfileApi'; @@ -53,6 +55,7 @@ const WS_ATTRIBUTES_TO_SAVE: string[] = [ 'workspaceDeploymentAnnotations', 'policies.create', 'che-editor', + 'devWorkspace', ]; export type CreatePolicy = 'perclick' | 'peruser'; @@ -81,6 +84,7 @@ type State = { hasError: boolean; createPolicy: CreatePolicy; cheDevworkspaceEnabled: boolean; + createFromDevfile: boolean; // indicates that a devfile is used to create a workspace }; export class FactoryLoaderContainer extends React.PureComponent { @@ -105,6 +109,7 @@ export class FactoryLoaderContainer extends React.PureComponent { createPolicy, search, cheDevworkspaceEnabled, + createFromDevfile: false, }; } @@ -405,8 +410,8 @@ export class FactoryLoaderContainer extends React.PureComponent { const annotations = workspace.ref.metadata.annotations; const source = annotations ? annotations[DEVWORKSPACE_DEVFILE_SOURCE] : undefined; if (source) { - const sourseObj = safeLoad(source) as FactorySource; - return sourseObj?.factory?.params === attrs.factoryParams; + const sourceObj = safeLoad(source) as FactorySource; + return sourceObj?.factory?.params === attrs.factoryParams; } // ignore createPolicy for dev workspaces return false; @@ -458,6 +463,67 @@ export class FactoryLoaderContainer extends React.PureComponent { return workspace; } + private async createDevWorkspaceFromResources( + devWorkspacePrebuiltResources: string, + factoryParams: string, + ): Promise { + let workspace: Workspace | undefined; + + // creation policy is `peruser` + workspace = this.props.allWorkspaces.find(workspace => { + if (isCheWorkspace(workspace.ref)) { + return false; + } else { + const annotations = workspace.ref.metadata.annotations; + const source = annotations ? annotations[DEVWORKSPACE_DEVFILE_SOURCE] : undefined; + if (source) { + const sourceObj = safeLoad(source) as FactorySource; + return sourceObj?.factory?.params === factoryParams; + } + return false; + } + }); + + if (!workspace) { + try { + let yamlContent: string; + try { + const response = await axios.get(devWorkspacePrebuiltResources); + yamlContent = response.data; + } catch (e) { + const errorMessage = common.helpers.errors.getMessage(e); + console.error( + `Failed to fetch prebuilt resources from ${devWorkspacePrebuiltResources}. ${errorMessage}`, + ); + this.showAlert(`Failed to fetch prebuilt resources. ${errorMessage}`); + return; + } + + const resources = safeLoadAll(yamlContent); + const devworkspace = resources.find( + resource => resource.kind === 'DevWorkspace', + ) as devfileApi.DevWorkspace; + const devworkspaceTemplate = resources.find( + resource => resource.kind === 'DevWorkspaceTemplate', + ) as devfileApi.DevWorkspaceTemplate; + + await this.props.createWorkspaceFromResources(devworkspace, devworkspaceTemplate); + + const namespace = this.props.defaultNamespace?.name; + this.props.setWorkspaceQualifiedName(namespace, devworkspace.metadata.name as string); + workspace = this.props.activeWorkspace; + } catch (e) { + this.showAlert(`Failed to create a workspace. ${e}`); + return; + } + } + if (!workspace) { + this.showAlert('Failed to create a workspace.'); + return; + } + return workspace; + } + private tryAgainHandler(): void { const searchParams = new window.URLSearchParams(this.props.history.location.search); searchParams.delete('error_code'); @@ -552,18 +618,32 @@ export class FactoryLoaderContainer extends React.PureComponent { await delay(); - let devfile = await this.resolveDevfile(location); + let workspace: Workspace | undefined; + if (this.props.cheDevworkspaceEnabled && attrs.devWorkspace) { + // create workspace using prebuilt resources + workspace = await this.createDevWorkspaceFromResources( + attrs.devWorkspace, + attrs.factoryParams, + ); + } else { + // create workspace using a devfile + this.setState({ + createFromDevfile: true, + }); + + let devfile = await this.resolveDevfile(location); - if (!devfile) { - return; - } + if (!devfile) { + return; + } - devfile = updateDevfileMetadata(devfile, attrs.factoryParams, createPolicy); - this.setState({ currentStep: LoadFactorySteps.APPLYING_DEVFILE }); + devfile = updateDevfileMetadata(devfile, attrs.factoryParams, createPolicy); + this.setState({ currentStep: LoadFactorySteps.APPLYING_DEVFILE }); - await delay(); + await delay(); - const workspace = await this.resolveWorkspace(devfile, attrs); + workspace = await this.resolveWorkspace(devfile, attrs); + } if (!workspace) { return; @@ -578,7 +658,13 @@ export class FactoryLoaderContainer extends React.PureComponent { render() { const { workspace } = this.props; - const { currentStep, resolvedDevfileMessage, hasError, cheDevworkspaceEnabled } = this.state; + const { + currentStep, + resolvedDevfileMessage, + hasError, + cheDevworkspaceEnabled, + createFromDevfile, + } = this.state; const workspaceName = workspace ? workspace.name : ''; const workspaceId = workspace ? workspace.id : ''; @@ -589,6 +675,7 @@ export class FactoryLoaderContainer extends React.PureComponent { resolvedDevfileMessage={resolvedDevfileMessage} workspaceId={workspaceId} workspaceName={workspaceName} + createFromDevfile={createFromDevfile} isDevWorkspace={cheDevworkspaceEnabled} callbacks={this.factoryLoaderCallbacks} /> @@ -610,7 +697,8 @@ const mapStateToProps = (state: AppState) => ({ const connector = connect(mapStateToProps, { ...FactoryResolverStore.actionCreators, - ...WorkspaceStore.actionCreators, + ...WorkspacesStore.actionCreators, + createWorkspaceFromResources: DevWorkspacesStore.actionCreators.createWorkspaceFromResources, }); type MappedProps = ConnectedProps; diff --git a/packages/dashboard-frontend/src/containers/__tests__/FactoryLoader.spec.tsx b/packages/dashboard-frontend/src/containers/__tests__/FactoryLoader.spec.tsx index 6cda3aae1..364e3961e 100644 --- a/packages/dashboard-frontend/src/containers/__tests__/FactoryLoader.spec.tsx +++ b/packages/dashboard-frontend/src/containers/__tests__/FactoryLoader.spec.tsx @@ -14,6 +14,7 @@ import React from 'react'; import { Provider } from 'react-redux'; import { AlertVariant } from '@patternfly/react-core'; import { RenderResult, render, screen, waitFor } from '@testing-library/react'; +import mockAxios from 'axios'; import { ROUTE } from '../../route.enum'; import { getMockRouterProps } from '../../services/__mocks__/router'; import { FakeStoreBuilder } from '../../store/__mocks__/storeBuilder'; @@ -42,6 +43,18 @@ const requestFactoryResolverMock = jest.fn().mockResolvedValue(undefined); const setWorkspaceIdMock = jest.fn().mockResolvedValue(undefined); const clearWorkspaceIdMock = jest.fn().mockResolvedValue(undefined); +const createWorkspaceFromResourcesMock = jest.fn().mockReturnValue(undefined); +jest.mock('../../store/Workspaces/devWorkspaces/index', () => { + return { + actionCreators: { + createWorkspaceFromResources: + (devworkspace: string, devworkspaceTemplate: string) => async (): Promise => { + createWorkspaceFromResourcesMock(devworkspace, devworkspaceTemplate); + }, + }, + }; +}); + jest.mock('../../store/Workspaces'); (workspacesActionCreators.requestWorkspace as jest.Mock).mockImplementation( (id: string) => async () => requestWorkspaceMock(id), @@ -167,6 +180,42 @@ describe('Factory Loader container', () => { }); }); + describe('with prebuilt resources', () => { + it('should start creating a devworkspace using pre-generated resources', async () => { + const store = new FakeStoreBuilder() + .withWorkspacesSettings({ + 'che.devworkspaces.enabled': 'true', + } as che.WorkspaceSettings) + .withInfrastructureNamespace([{ name: namespace, attributes: { phase: 'Active' } }], false) + .build(); + + const location = 'http://test-location'; + const props = getMockRouterProps(ROUTE.LOAD_FACTORY_URL, { + url: location, + }); + props.location.search += '&devWorkspace=devWorkspace.yaml'; + + const yamlContent = `apiVersion: workspace.devfile.io/v1alpha2 +kind: DevWorkspaceTemplate +metadata: + name: theia-ide-project +--- +apiVersion: workspace.devfile.io/v1alpha2 +kind: DevWorkspace +metadata: + name: project`; + (mockAxios.get as jest.Mock).mockResolvedValueOnce(yamlContent); + + render( + + + , + ); + + await waitFor(() => expect(createWorkspaceFromResourcesMock).toHaveBeenCalled()); + }); + }); + describe('Use a devfile V1', () => { it('should resolve the factory, create and start a new workspace', async () => { const location = 'http://test-location'; diff --git a/packages/dashboard-frontend/src/pages/FactoryLoader/__tests__/FactoryLoader.spec.tsx b/packages/dashboard-frontend/src/pages/FactoryLoader/__tests__/FactoryLoader.spec.tsx index 08b75c72e..ce67f2a42 100644 --- a/packages/dashboard-frontend/src/pages/FactoryLoader/__tests__/FactoryLoader.spec.tsx +++ b/packages/dashboard-frontend/src/pages/FactoryLoader/__tests__/FactoryLoader.spec.tsx @@ -141,6 +141,7 @@ function renderComponent( hasError={hasError} resolvedDevfileMessage={resolvedDevfileMessage} isDevWorkspace + createFromDevfile={true} /> , ); diff --git a/packages/dashboard-frontend/src/pages/FactoryLoader/index.tsx b/packages/dashboard-frontend/src/pages/FactoryLoader/index.tsx index 4b3797bf5..5ae941a02 100644 --- a/packages/dashboard-frontend/src/pages/FactoryLoader/index.tsx +++ b/packages/dashboard-frontend/src/pages/FactoryLoader/index.tsx @@ -55,6 +55,7 @@ type Props = { workspaceId: string; isDevWorkspace: boolean; resolvedDevfileMessage?: string; + createFromDevfile: boolean; callbacks?: { showAlert?: (options: AlertOptions) => void; }; @@ -170,49 +171,60 @@ class FactoryLoader extends React.PureComponent { ); }; - return [ - { - id: LoadFactorySteps.INITIALIZING, - name: getTitle(LoadFactorySteps.INITIALIZING, 'Initializing', 'wizard-icon'), - canJumpTo: currentStep >= LoadFactorySteps.INITIALIZING, - }, - { - name: getTitle(LoadFactorySteps.CREATE_WORKSPACE, 'Creating a workspace', 'wizard-icon'), - steps: [ - { - id: LoadFactorySteps.LOOKING_FOR_DEVFILE, - name: getTitle( - LoadFactorySteps.LOOKING_FOR_DEVFILE, - currentStep <= LoadFactorySteps.LOOKING_FOR_DEVFILE - ? 'Looking for devfile' - : resolvedDevfileMessage - ? `${resolvedDevfileMessage}` - : 'Devfile could not be found', - ), - canJumpTo: currentStep >= LoadFactorySteps.LOOKING_FOR_DEVFILE, - }, - { - id: LoadFactorySteps.APPLYING_DEVFILE, - name: getTitle(LoadFactorySteps.APPLYING_DEVFILE, 'Applying devfile'), - canJumpTo: currentStep >= LoadFactorySteps.APPLYING_DEVFILE, - }, - ], - }, - { - id: LoadFactorySteps.START_WORKSPACE, - name: getTitle( - LoadFactorySteps.START_WORKSPACE, - 'Waiting for workspace to start', - 'wizard-icon', - ), - canJumpTo: currentStep >= LoadFactorySteps.START_WORKSPACE, - }, - { - id: LoadFactorySteps.OPEN_IDE, - name: getTitle(LoadFactorySteps.OPEN_IDE, 'Open IDE', 'wizard-icon'), - canJumpTo: currentStep >= LoadFactorySteps.OPEN_IDE, - }, + const initializingStep: WizardStep = { + id: LoadFactorySteps.INITIALIZING, + name: getTitle(LoadFactorySteps.INITIALIZING, 'Initializing', 'wizard-icon'), + canJumpTo: currentStep >= LoadFactorySteps.INITIALIZING, + }; + const createWorkspaceStep: WizardStep = { + name: getTitle(LoadFactorySteps.CREATE_WORKSPACE, 'Creating a workspace', 'wizard-icon'), + canJumpTo: currentStep >= LoadFactorySteps.CREATE_WORKSPACE, + }; + const startWorkspaceStep: WizardStep = { + id: LoadFactorySteps.START_WORKSPACE, + name: getTitle( + LoadFactorySteps.START_WORKSPACE, + 'Waiting for workspace to start', + 'wizard-icon', + ), + canJumpTo: currentStep >= LoadFactorySteps.START_WORKSPACE, + }; + const openIdeStep: WizardStep = { + id: LoadFactorySteps.OPEN_IDE, + name: getTitle(LoadFactorySteps.OPEN_IDE, 'Open IDE', 'wizard-icon'), + canJumpTo: currentStep >= LoadFactorySteps.OPEN_IDE, + }; + const steps: WizardStep[] = [ + initializingStep, + createWorkspaceStep, + startWorkspaceStep, + openIdeStep, ]; + + if (this.props.createFromDevfile) { + createWorkspaceStep.id = LoadFactorySteps.CREATE_WORKSPACE; + createWorkspaceStep.steps = [ + { + id: LoadFactorySteps.LOOKING_FOR_DEVFILE, + name: getTitle( + LoadFactorySteps.LOOKING_FOR_DEVFILE, + currentStep <= LoadFactorySteps.LOOKING_FOR_DEVFILE + ? 'Looking for devfile' + : resolvedDevfileMessage + ? `${resolvedDevfileMessage}` + : 'Devfile could not be found', + ), + canJumpTo: currentStep >= LoadFactorySteps.LOOKING_FOR_DEVFILE, + }, + { + id: LoadFactorySteps.APPLYING_DEVFILE, + name: getTitle(LoadFactorySteps.APPLYING_DEVFILE, 'Applying devfile'), + canJumpTo: currentStep >= LoadFactorySteps.APPLYING_DEVFILE, + }, + ]; + } + + return steps; } public render(): React.ReactElement { diff --git a/packages/dashboard-frontend/src/pages/GetStarted/CustomWorkspaceTab/DevfileSelector/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/CustomWorkspaceTab/DevfileSelector/index.tsx index fb5ca37fa..ee4de8d7d 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/CustomWorkspaceTab/DevfileSelector/index.tsx +++ b/packages/dashboard-frontend/src/pages/GetStarted/CustomWorkspaceTab/DevfileSelector/index.tsx @@ -85,7 +85,7 @@ export class DevfileSelectorFormGroup extends React.PureComponent let devfile: api.che.workspace.devfile.Devfile; try { if (cheDevworkspaceEnabled) { - await this.props.requestFactoryResolver(meta.links.v2); + await this.props.requestFactoryResolver(meta.links.v2 as string); // at this point the resolver object is defined // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const resolvedDevfile = this.props.factoryResolver.resolver!.devfile; diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/SamplesListGallery.tsx b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/SamplesListGallery.tsx index cc93e9461..0a8dc8785 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/SamplesListGallery.tsx +++ b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/SamplesListGallery.tsx @@ -35,6 +35,7 @@ import { selectMetadataFiltered } from '../../../store/DevfileRegistries/selecto import { selectWorkspacesSettings } from '../../../store/Workspaces/Settings/selectors'; import * as FactoryResolverStore from '../../../store/FactoryResolver'; import { isDevworkspacesEnabled } from '../../../services/helpers/devworkspace'; +import { selectDefaultEditor } from '../../../store/Plugins/devWorkspacePlugins/selectors'; type Props = MappedProps & { onCardClick: ( @@ -97,20 +98,24 @@ export class SamplesListGallery extends React.PureComponent { this.isLoading = true; try { const cheDevworkspaceEnabled = isDevworkspacesEnabled(this.props.workspacesSettings); - let devfileContent; - let optionalFilesContent; if (cheDevworkspaceEnabled) { const link = meta.links.v2; + let devWorkspace = ''; + if (this.props.defaultEditor) { + const prebuiltDevWorkspace = meta.links.devWorkspaces?.[this.props.defaultEditor]; + devWorkspace = prebuiltDevWorkspace + ? `?devWorkspace=${encodeURIComponent(prebuiltDevWorkspace)}` + : ''; + } // use factory workflow to load the getting started samples - const factoryUrl = `${window.location.origin}/#${link}`; + const factoryUrl = `${window.location.origin}/#${link}${devWorkspace}`; // open a new page to handle that window.open(factoryUrl, '_blank'); this.isLoading = false; return; - } else { - devfileContent = (await this.props.requestDevfile(meta.links.self)) as string; } - this.props.onCardClick(devfileContent, meta.displayName, optionalFilesContent); + const devfileContent = (await this.props.requestDevfile(meta.links.self)) as string; + this.props.onCardClick(devfileContent, meta.displayName); } catch (e) { console.warn('Failed to load devfile.', e); @@ -159,6 +164,7 @@ const mapStateToProps = (state: AppState) => ({ metadataFiltered: selectMetadataFiltered(state), workspacesSettings: selectWorkspacesSettings(state), factoryResolver: state.factoryResolver, + defaultEditor: selectDefaultEditor(state), }); const connector = connect(mapStateToProps, { diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/EditorTab/index.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/EditorTab/index.tsx index ddabcb9ab..5c7525a58 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/EditorTab/index.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/EditorTab/index.tsx @@ -31,7 +31,6 @@ import DevfileEditor, { DevfileEditor as Editor } from '../../../components/Devf import EditorTools from './EditorTools'; import { convertWorkspace, isCheWorkspace, Workspace } from '../../../services/workspace-adapter'; import devfileApi, { isDevfileV2, isDevWorkspace } from '../../../services/devfileApi'; -import { DevWorkspaceStatus } from '../../../services/helpers/types'; import { DevWorkspaceClient, DEVWORKSPACE_NEXT_START_ANNOTATION, diff --git a/packages/dashboard-frontend/src/services/bootstrap/index.ts b/packages/dashboard-frontend/src/services/bootstrap/index.ts index ad0f6cb5c..33f26afa7 100644 --- a/packages/dashboard-frontend/src/services/bootstrap/index.ts +++ b/packages/dashboard-frontend/src/services/bootstrap/index.ts @@ -34,6 +34,8 @@ import { DevWorkspaceClient } from '../workspace-client/devworkspace/devWorkspac import { isDevworkspacesEnabled } from '../helpers/devworkspace'; import { selectDwEditorsPluginsList } from '../../store/Plugins/devWorkspacePlugins/selectors'; import devfileApi from '../devfileApi'; +import { selectDefaultNamespace } from '../../store/InfrastructureNamespaces/selectors'; +import { selectDevWorkspacesResourceVersion } from '../../store/Workspaces/devWorkspaces/selectors'; /** * This class executes a few initial instructions @@ -70,12 +72,11 @@ export default class Bootstrap { const results = await Promise.allSettled([ this.fetchCurrentUser(), this.fetchUserProfile(), - this.fetchPlugins(settings).then(() => { - return this.fetchDevfileSchema(); - }), + this.fetchPlugins(settings).then(() => this.fetchDevfileSchema()), this.fetchDwPlugins(settings), this.fetchDefaultDwPlugins(settings), this.fetchRegistriesMetadata(settings), + this.watchNamespaces(), this.updateDevWorkspaceTemplates(settings), this.fetchWorkspaces(), this.fetchApplications(), @@ -94,7 +95,7 @@ export default class Bootstrap { await requestClusterInfo()(this.store.dispatch, this.store.getState, undefined); } catch (e) { console.warn( - 'Unable to fetch cluster info. This is expected behaviour unless backend is configured to provide this information.', + 'Unable to fetch cluster info. This is expected behavior unless backend is configured to provide this information.', ); } } @@ -113,17 +114,13 @@ export default class Bootstrap { return this.cheWorkspaceClient.updateJsonRpcMasterApi(); } - private async watchNamespaces(namespace: string): Promise { - const { - updateDevWorkspaceStatus, - updateDeletedDevWorkspaces, - updateAddedDevWorkspaces, - requestWorkspaces, - } = DevWorkspacesStore.actionCreators; - const getResourceVersion = async () => { - await requestWorkspaces()(this.store.dispatch, this.store.getState, undefined); - return this.store.getState().devWorkspaces.resourceVersion; - }; + private async watchNamespaces(): Promise { + const defaultKubernetesNamespace = selectDefaultNamespace(this.store.getState()); + const namespace = defaultKubernetesNamespace.name; + const { updateDevWorkspaceStatus, updateDeletedDevWorkspaces, updateAddedDevWorkspaces } = + DevWorkspacesStore.actionCreators; + const getResourceVersion = async () => + selectDevWorkspacesResourceVersion(this.store.getState()); const dispatch = this.store.dispatch; const getState = this.store.getState; const callbacks = { @@ -162,8 +159,6 @@ export default class Bootstrap { if (!isDevworkspacesEnabled(settings)) { return; } - const defaultNamespace = await this.cheWorkspaceClient.getDefaultNamespace(); - await this.watchNamespaces(defaultNamespace); const { requestDwDefaultEditor } = DwPluginsStore.actionCreators; try { await requestDwDefaultEditor(settings)(this.store.dispatch, this.store.getState, undefined); @@ -192,7 +187,8 @@ export default class Bootstrap { if (!isDevworkspacesEnabled(settings)) { return; } - const defaultNamespace = await this.cheWorkspaceClient.getDefaultNamespace(); + const defaultKubernetesNamespace = selectDefaultNamespace(this.store.getState()); + const defaultNamespace = defaultKubernetesNamespace.name; try { const pluginsByUrl: { [url: string]: devfileApi.Devfile } = {}; const state = this.store.getState(); diff --git a/packages/dashboard-frontend/src/services/helpers/__tests__/location.spec.ts b/packages/dashboard-frontend/src/services/helpers/__tests__/location.spec.ts index 9e5004524..3be0fa45b 100644 --- a/packages/dashboard-frontend/src/services/helpers/__tests__/location.spec.ts +++ b/packages/dashboard-frontend/src/services/helpers/__tests__/location.spec.ts @@ -39,4 +39,13 @@ describe('Location test', () => { '?url=https%3A%2F%2Fgithub.com%2Fche-samples%2Fjava-spring-petclinic%2Ftree%2Fdevfilev2&override.devfileFilename=devfilev2.yaml', ); }); + + test('devWorkspace parameter', () => { + const result = buildFactoryLoaderLocation( + 'https://github.com/che-samples/java-spring-petclinic/tree/devfilev2?devWorkspace=/devfiles/devworkspace-che-theia-latest.yaml', + ); + expect(result.search).toBe( + '?url=https%3A%2F%2Fgithub.com%2Fche-samples%2Fjava-spring-petclinic%2Ftree%2Fdevfilev2&devWorkspace=%2Fdevfiles%2Fdevworkspace-che-theia-latest.yaml', + ); + }); }); diff --git a/packages/dashboard-frontend/src/services/helpers/location.ts b/packages/dashboard-frontend/src/services/helpers/location.ts index 0aade4533..52c557e08 100644 --- a/packages/dashboard-frontend/src/services/helpers/location.ts +++ b/packages/dashboard-frontend/src/services/helpers/location.ts @@ -52,6 +52,9 @@ export function buildFactoryLoaderLocation(url?: string): Location { devfilePath = extractUrlParam(fullUrl, 'df'); } + // look for prebuilt devworkspace, remove it from URL + const devWorkspace = extractUrlParam(fullUrl, 'devWorkspace'); + // creation policy const newWorkspace = extractUrlParam(fullUrl, 'new'); const encodedUrl = encodeURIComponent(fullUrl.toString()); @@ -67,6 +70,9 @@ export function buildFactoryLoaderLocation(url?: string): Location { if (newWorkspace) { pathAndQuery = `${pathAndQuery}&policies.create=perclick`; } + if (devWorkspace) { + pathAndQuery = `${pathAndQuery}&devWorkspace=${encodeURIComponent(devWorkspace)}`; + } } return _buildLocationObject(pathAndQuery); } diff --git a/packages/dashboard-frontend/src/services/registry/devfiles.spec.ts b/packages/dashboard-frontend/src/services/registry/devfiles.spec.ts index 19caba89c..cd077b7fd 100644 --- a/packages/dashboard-frontend/src/services/registry/devfiles.spec.ts +++ b/packages/dashboard-frontend/src/services/registry/devfiles.spec.ts @@ -17,6 +17,9 @@ describe('devfile links', () => { it('should update links that are not absolute', () => { const metadata = { + displayName: 'nodejs-react', + icon: '/icon.png', + tags: [], links: { v2: 'https://github.com/che-samples/nodejs-react-redux/tree/devfilev2', self: '/devfiles/nodejs-react/devfile.yaml', diff --git a/packages/dashboard-frontend/src/services/workspace-client/cheworkspace/cheWorkspaceClient.ts b/packages/dashboard-frontend/src/services/workspace-client/cheworkspace/cheWorkspaceClient.ts index 3c8cb354f..5222cf583 100644 --- a/packages/dashboard-frontend/src/services/workspace-client/cheworkspace/cheWorkspaceClient.ts +++ b/packages/dashboard-frontend/src/services/workspace-client/cheworkspace/cheWorkspaceClient.ts @@ -107,22 +107,4 @@ export class CheWorkspaceClient extends WorkspaceClient { get failingWebSockets(): string[] { return Array.from(this._failingWebSockets); } - - async getDefaultNamespace(): Promise { - if (this.defaultNamespace) { - return this.defaultNamespace; - } - const namespaces = await this.restApiClient.getKubernetesNamespace(); - if (namespaces.length === 1) { - return namespaces[0].name; - } - const defaultNamespace = namespaces.filter( - kubernetesNamespace => kubernetesNamespace.attributes.default === 'true', - ); - if (defaultNamespace.length === 0) { - throw new Error('Default namespace is not found'); - } - this.defaultNamespace = defaultNamespace[0].name; - return this.defaultNamespace; - } } diff --git a/packages/dashboard-frontend/src/services/workspace-client/devworkspace/__tests__/devWorkspaceClient.create.spec.ts b/packages/dashboard-frontend/src/services/workspace-client/devworkspace/__tests__/devWorkspaceClient.create.spec.ts index e91423a85..2ade10c45 100644 --- a/packages/dashboard-frontend/src/services/workspace-client/devworkspace/__tests__/devWorkspaceClient.create.spec.ts +++ b/packages/dashboard-frontend/src/services/workspace-client/devworkspace/__tests__/devWorkspaceClient.create.spec.ts @@ -60,7 +60,7 @@ describe('DevWorkspace client, create', () => { .mockResolvedValueOnce(testWorkspace); jest.spyOn(DwApi, 'patchWorkspace').mockResolvedValueOnce(testWorkspace); - await client.create(testDevfile, namespace, [], undefined, undefined, {}); + await client.createFromDevfile(testDevfile, namespace, [], undefined, undefined, {}); expect(spyCreateWorkspace).toBeCalledWith( expect.objectContaining({ diff --git a/packages/dashboard-frontend/src/services/workspace-client/devworkspace/devWorkspaceClient.ts b/packages/dashboard-frontend/src/services/workspace-client/devworkspace/devWorkspaceClient.ts index 2d187c4cb..debcb4d93 100644 --- a/packages/dashboard-frontend/src/services/workspace-client/devworkspace/devWorkspaceClient.ts +++ b/packages/dashboard-frontend/src/services/workspace-client/devworkspace/devWorkspaceClient.ts @@ -247,7 +247,28 @@ export class DevWorkspaceClient extends WorkspaceClient { return workspace; } - async create( + async createFromResources( + defaultNamespace: string, + devworkspace: devfileApi.DevWorkspace, + devworkspaceTemplate: devfileApi.DevWorkspaceTemplate, + ): Promise { + // create DWT + devworkspaceTemplate.metadata.namespace = defaultNamespace; + await DwtApi.createTemplate(devworkspaceTemplate); + + // create DW + devworkspace.spec.routingClass = 'che'; + devworkspace.metadata.namespace = defaultNamespace; + if (devworkspace.metadata.annotations === undefined) { + devworkspace.metadata.annotations = {}; + } + devworkspace.metadata.annotations[DEVWORKSPACE_UPDATING_TIMESTAMP_ANNOTATION] = + new Date().toISOString(); + + return DwApi.createWorkspace(devworkspace); + } + + async createFromDevfile( devfile: devfileApi.Devfile, defaultNamespace: string, dwEditorsPlugins: { devfile: devfileApi.Devfile; url: string }[], diff --git a/packages/dashboard-frontend/src/store/DevfileRegistries/__tests__/index.spec.ts b/packages/dashboard-frontend/src/store/DevfileRegistries/__tests__/index.spec.ts new file mode 100644 index 000000000..0f8b63e78 --- /dev/null +++ b/packages/dashboard-frontend/src/store/DevfileRegistries/__tests__/index.spec.ts @@ -0,0 +1,645 @@ +/* + * Copyright (c) 2018-2021 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import mockAxios from 'axios'; +import { MockStoreEnhanced } from 'redux-mock-store'; +import { ThunkDispatch } from 'redux-thunk'; +import { FakeStoreBuilder } from '../../__mocks__/storeBuilder'; +import * as devfileRegistriesStore from '..'; +import { AppState } from '../..'; +import { container } from '../../../inversify.config'; +import { CheWorkspaceClient } from '../../../services/workspace-client/cheworkspace/cheWorkspaceClient'; +import { AnyAction } from 'redux'; + +// mute error outputs +console.error = jest.fn(); + +describe('Devfile registries', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('actions', () => { + it('should create REQUEST_REGISTRY_METADATA, RECEIVE_REGISTRY_METADATA when fetching registries', async () => { + const registryMetadataV1 = getMetadataV1(); + const registryMetadataV2 = getMetadataV2(); + (mockAxios.get as jest.Mock) + .mockResolvedValueOnce({ + data: registryMetadataV1, + }) + .mockResolvedValueOnce({ + data: registryMetadataV2, + }) + .mockResolvedValue({ + data: [], + }); + + const store = new FakeStoreBuilder().build() as MockStoreEnhanced< + AppState, + ThunkDispatch + >; + + const location1 = 'http://example.com/location1'; + const location2 = 'http://example.com/location2'; + const location3 = 'http://example.com/location3'; + const locations = `${location1} ${location2} ${location3}`; + await store.dispatch( + devfileRegistriesStore.actionCreators.requestRegistriesMetadata(locations), + ); + + const actions = store.getActions(); + + const expectedActions: devfileRegistriesStore.KnownAction[] = [ + { + type: devfileRegistriesStore.Type.REQUEST_REGISTRY_METADATA, + }, + { + type: devfileRegistriesStore.Type.RECEIVE_REGISTRY_METADATA, + url: location1, + metadata: expect.arrayContaining([ + expect.objectContaining({ displayName: registryMetadataV1[0].displayName }), + ]), + }, + { + type: devfileRegistriesStore.Type.RECEIVE_REGISTRY_METADATA, + url: location2, + metadata: expect.arrayContaining([ + expect.objectContaining({ displayName: registryMetadataV2[0].displayName }), + ]), + }, + ]; + + expect(actions).toEqual(expectedActions); + }); + + it('should create REQUEST_REGISTRY_METADATA and RECEIVE_REGISTRY_ERROR when failed to fetch a registry', async () => { + const registryMetadataV1 = getMetadataV1(); + const registryMetadataV2 = getMetadataV2(); + (mockAxios.get as jest.Mock) + .mockResolvedValueOnce({ + data: registryMetadataV1, + }) + .mockResolvedValueOnce({ + data: registryMetadataV2, + }) + .mockRejectedValueOnce({ + data: undefined, + }); + + const store = new FakeStoreBuilder().build() as MockStoreEnhanced< + AppState, + ThunkDispatch + >; + + const location1 = 'http://example.com/location1'; + const location2 = 'http://example.com/location2'; + const location3 = 'http://example.com/location3'; + const locations = `${location1} ${location2} ${location3}`; + + await expect( + store.dispatch(devfileRegistriesStore.actionCreators.requestRegistriesMetadata(locations)), + ).rejects.toMatch('Failed to fetch devfiles metadata from registry'); + + const actions = store.getActions(); + + const expectedActions: devfileRegistriesStore.KnownAction[] = [ + { + type: devfileRegistriesStore.Type.REQUEST_REGISTRY_METADATA, + }, + { + type: devfileRegistriesStore.Type.RECEIVE_REGISTRY_METADATA, + url: location1, + metadata: expect.arrayContaining([ + expect.objectContaining({ displayName: registryMetadataV1[0].displayName }), + ]), + }, + { + type: devfileRegistriesStore.Type.RECEIVE_REGISTRY_METADATA, + url: location2, + metadata: expect.arrayContaining([ + expect.objectContaining({ displayName: registryMetadataV2[0].displayName }), + ]), + }, + { + type: devfileRegistriesStore.Type.RECEIVE_REGISTRY_ERROR, + url: location3, + error: expect.stringContaining(location3), + }, + ]; + + expect(actions).toEqual(expectedActions); + }); + + it('should create REQUEST_DEVFILE and RECEIVE_DEVFILE when fetching a devfile', async () => { + (mockAxios.get as jest.Mock).mockResolvedValueOnce({ + data: 'a devfile content', + }); + + const store = new FakeStoreBuilder().build() as MockStoreEnhanced< + AppState, + ThunkDispatch + >; + + const devfileUrl = 'http://example.com/devfile.yaml'; + await store.dispatch(devfileRegistriesStore.actionCreators.requestDevfile(devfileUrl)); + + const actions = store.getActions(); + + const expectedActions: devfileRegistriesStore.KnownAction[] = [ + { + type: devfileRegistriesStore.Type.REQUEST_DEVFILE, + }, + { + type: devfileRegistriesStore.Type.RECEIVE_DEVFILE, + url: devfileUrl, + devfile: 'a devfile content', + }, + ]; + + expect(actions).toEqual(expectedActions); + }); + + it('should create REQUEST_SCHEMA and RECEIVE_SCHEMA when fetching the devfile v1 schema', async () => { + const schemaV1 = getSchemaV1(); + const cheWorkspaceClient = container.get(CheWorkspaceClient); + const spyGetDevfileSchema = jest + .spyOn(cheWorkspaceClient.restApiClient, 'getDevfileSchema') + .mockResolvedValueOnce(schemaV1); + + const store = new FakeStoreBuilder() + .withWorkspacesSettings({ + 'che.devworkspaces.enabled': 'false', + } as che.WorkspaceSettings) + .build() as MockStoreEnhanced< + AppState, + ThunkDispatch + >; + + await store.dispatch(devfileRegistriesStore.actionCreators.requestJsonSchema()); + + const actions = store.getActions(); + + const expectedActions: devfileRegistriesStore.KnownAction[] = [ + { + type: devfileRegistriesStore.Type.REQUEST_SCHEMA, + }, + { + type: devfileRegistriesStore.Type.RECEIVE_SCHEMA, + schema: schemaV1, + }, + ]; + + expect(actions).toEqual(expectedActions); + spyGetDevfileSchema.mockRestore(); + }); + + it('should create REQUEST_SCHEMA and RECEIVE_SCHEMA when fetching all devfile schemas', async () => { + const schemaV1 = getSchemaV1(); + const schemaV200 = getSchemaV200(); + const schemaV210 = getSchemaV210(); + const schemaV220 = getSchemaV220(); + const cheWorkspaceClient = container.get(CheWorkspaceClient); + const spyGetDevfileSchema = jest + .spyOn(cheWorkspaceClient.restApiClient, 'getDevfileSchema') + .mockResolvedValueOnce(schemaV1) + .mockResolvedValueOnce(schemaV200) + .mockResolvedValueOnce(schemaV210) + .mockResolvedValueOnce(schemaV220); + + const store = new FakeStoreBuilder() + .withWorkspacesSettings({ + 'che.devworkspaces.enabled': 'true', + } as che.WorkspaceSettings) + .build() as MockStoreEnhanced< + AppState, + ThunkDispatch + >; + + await store.dispatch(devfileRegistriesStore.actionCreators.requestJsonSchema()); + + const actions = store.getActions(); + + const expectedActions: devfileRegistriesStore.KnownAction[] = [ + { + type: devfileRegistriesStore.Type.REQUEST_SCHEMA, + }, + { + type: devfileRegistriesStore.Type.RECEIVE_SCHEMA, + schema: { + oneOf: expect.arrayContaining([schemaV1, schemaV200, schemaV210, schemaV220]), + }, + }, + ]; + + expect(actions).toEqual(expectedActions); + spyGetDevfileSchema.mockRestore(); + }); + + it('should create REQUEST_SCHEMA and RECEIVE_SCHEMA_ERROR when failed to fetch devfile schemas', async () => { + const cheWorkspaceClient = container.get(CheWorkspaceClient); + const errorMessage = 'error message'; + const spyGetDevfileSchema = jest + .spyOn(cheWorkspaceClient.restApiClient, 'getDevfileSchema') + .mockRejectedValueOnce(errorMessage); + + const store = new FakeStoreBuilder() + .withWorkspacesSettings({ + 'che.devworkspaces.enabled': 'false', + } as che.WorkspaceSettings) + .build() as MockStoreEnhanced< + AppState, + ThunkDispatch + >; + + await expect( + store.dispatch(devfileRegistriesStore.actionCreators.requestJsonSchema()), + ).rejects.toMatch('Failed to request devfile JSON schema'); + + const actions = store.getActions(); + + const expectedActions: devfileRegistriesStore.KnownAction[] = [ + { + type: devfileRegistriesStore.Type.REQUEST_SCHEMA, + }, + { + type: devfileRegistriesStore.Type.RECEIVE_SCHEMA_ERROR, + error: expect.stringContaining(errorMessage), + }, + ]; + + expect(actions).toEqual(expectedActions); + spyGetDevfileSchema.mockRestore(); + }); + }); + + describe('reducer', () => { + it('should return initial state', () => { + const incomingAction: devfileRegistriesStore.RequestRegistryMetadataAction = { + type: devfileRegistriesStore.Type.REQUEST_REGISTRY_METADATA, + }; + const initialState = devfileRegistriesStore.reducer(undefined, incomingAction); + + const expectedState: devfileRegistriesStore.State = { + isLoading: false, + registries: {}, + devfiles: {}, + schema: {}, + filter: '', + }; + + expect(initialState).toEqual(expectedState); + }); + + it('should return state if action type is not matched', () => { + const initialState: devfileRegistriesStore.State = { + isLoading: true, + registries: {}, + devfiles: {}, + schema: {}, + filter: '', + }; + const incomingAction = { + type: 'OTHER_ACTION', + } as AnyAction; + const newState = devfileRegistriesStore.reducer(initialState, incomingAction); + + const expectedState: devfileRegistriesStore.State = { + isLoading: true, + registries: {}, + devfiles: {}, + schema: {}, + filter: '', + }; + expect(newState).toEqual(expectedState); + }); + + it('should should handle REQUEST_REGISTRY_METADATA', () => { + const initialState: devfileRegistriesStore.State = { + isLoading: false, + registries: {}, + devfiles: {}, + schema: {}, + filter: '', + }; + const incomingAction: devfileRegistriesStore.RequestRegistryMetadataAction = { + type: devfileRegistriesStore.Type.REQUEST_REGISTRY_METADATA, + }; + const newState = devfileRegistriesStore.reducer(initialState, incomingAction); + + const expectedState: devfileRegistriesStore.State = { + isLoading: true, + registries: {}, + devfiles: {}, + schema: {}, + filter: '', + }; + expect(newState).toEqual(expectedState); + }); + + it('should should handle RECEIVE_REGISTRY_METADATA', () => { + const initialState: devfileRegistriesStore.State = { + isLoading: true, + registries: {}, + devfiles: {}, + schema: {}, + filter: '', + }; + const url = 'http://example.com/devfiles/registry'; + const metadata = [...getMetadataV1(), ...getMetadataV2()]; + const incomingAction: devfileRegistriesStore.ReceiveRegistryMetadataAction = { + type: devfileRegistriesStore.Type.RECEIVE_REGISTRY_METADATA, + url, + metadata, + }; + const newState = devfileRegistriesStore.reducer(initialState, incomingAction); + + const expectedState: devfileRegistriesStore.State = { + isLoading: false, + registries: { + [url]: { metadata }, + }, + devfiles: {}, + schema: {}, + filter: '', + }; + expect(newState).toEqual(expectedState); + }); + + it('should should handle RECEIVE_REGISTRY_ERROR', () => { + const initialState: devfileRegistriesStore.State = { + isLoading: true, + registries: {}, + devfiles: {}, + schema: {}, + filter: '', + }; + const url = 'http://example.com/devfiles/registry'; + const error = 'error message'; + const incomingAction: devfileRegistriesStore.ReceiveRegistryErrorAction = { + type: devfileRegistriesStore.Type.RECEIVE_REGISTRY_ERROR, + url, + error, + }; + const newState = devfileRegistriesStore.reducer(initialState, incomingAction); + + const expectedState: devfileRegistriesStore.State = { + isLoading: false, + registries: { + [url]: { error }, + }, + devfiles: {}, + schema: {}, + filter: '', + }; + expect(newState).toEqual(expectedState); + }); + + it('should should handle REQUEST_DEVFILE', () => { + const initialState: devfileRegistriesStore.State = { + isLoading: false, + registries: {}, + devfiles: {}, + schema: {}, + filter: '', + }; + const incomingAction: devfileRegistriesStore.RequestDevfileAction = { + type: devfileRegistriesStore.Type.REQUEST_DEVFILE, + }; + const newState = devfileRegistriesStore.reducer(initialState, incomingAction); + + const expectedState: devfileRegistriesStore.State = { + isLoading: true, + registries: {}, + devfiles: {}, + schema: {}, + filter: '', + }; + expect(newState).toEqual(expectedState); + }); + + it('should should handle RECEIVE_DEVFILE', () => { + const initialState: devfileRegistriesStore.State = { + isLoading: true, + registries: {}, + devfiles: {}, + schema: {}, + filter: '', + }; + const url = 'http://example.com/devfile.yaml'; + const content = 'a devfile content'; + const incomingAction: devfileRegistriesStore.ReceiveDevfileAction = { + type: devfileRegistriesStore.Type.RECEIVE_DEVFILE, + url, + devfile: content, + }; + const newState = devfileRegistriesStore.reducer(initialState, incomingAction); + + const expectedState: devfileRegistriesStore.State = { + isLoading: false, + registries: {}, + devfiles: { + [url]: { content }, + }, + schema: {}, + filter: '', + }; + expect(newState).toEqual(expectedState); + }); + + it('should should handle REQUEST_SCHEMA', () => { + const initialState: devfileRegistriesStore.State = { + isLoading: false, + registries: {}, + devfiles: {}, + schema: {}, + filter: '', + }; + const incomingAction: devfileRegistriesStore.RequestSchemaAction = { + type: devfileRegistriesStore.Type.REQUEST_SCHEMA, + }; + const newState = devfileRegistriesStore.reducer(initialState, incomingAction); + + const expectedState: devfileRegistriesStore.State = { + isLoading: true, + registries: {}, + devfiles: {}, + schema: {}, + filter: '', + }; + expect(newState).toEqual(expectedState); + }); + + it('should should handle RECEIVE_SCHEMA', () => { + const initialState: devfileRegistriesStore.State = { + isLoading: true, + registries: {}, + devfiles: {}, + schema: {}, + filter: '', + }; + const schema = getSchemaV1(); + const incomingAction: devfileRegistriesStore.ReceiveSchemaAction = { + type: devfileRegistriesStore.Type.RECEIVE_SCHEMA, + schema, + }; + const newState = devfileRegistriesStore.reducer(initialState, incomingAction); + + const expectedState: devfileRegistriesStore.State = { + isLoading: false, + registries: {}, + devfiles: {}, + schema: { schema }, + filter: '', + }; + expect(newState).toEqual(expectedState); + }); + + it('should should handle RECEIVE_SCHEMA_ERROR', () => { + const initialState: devfileRegistriesStore.State = { + isLoading: true, + registries: {}, + devfiles: {}, + schema: {}, + filter: '', + }; + const error = 'error message'; + const incomingAction: devfileRegistriesStore.ReceiveSchemaErrorAction = { + type: devfileRegistriesStore.Type.RECEIVE_SCHEMA_ERROR, + error, + }; + const newState = devfileRegistriesStore.reducer(initialState, incomingAction); + + const expectedState: devfileRegistriesStore.State = { + isLoading: false, + registries: {}, + devfiles: {}, + schema: { + error, + }, + filter: '', + }; + expect(newState).toEqual(expectedState); + }); + }); +}); + +function getMetadataV1(): che.DevfileMetaData[] { + return [ + { + displayName: 'Java with JBoss EAP XP 3.0 Bootable Jar', + description: 'Java stack with OpenJDK 11, Maven 3.6.3 and JBoss EAP XP 3.0 Bootable Jar', + tags: ['Java', 'OpenJDK', 'Maven', 'EAP', 'Microprofile', 'EAP XP', 'Bootable Jar', 'UBI8'], + icon: '/images/type-jboss.svg', + links: { + self: '/devfiles/00_java11-maven-microprofile-bootable/devfile.yaml', + }, + }, + ]; +} +function getMetadataV2(): che.DevfileMetaData[] { + return [ + { + displayName: 'Go', + description: 'Stack with Go 1.14', + tags: ['Debian', 'Go'], + icon: '/images/go.svg', + links: { + v2: 'https://github.com/che-samples/golang-echo-example/tree/devfile2', + devWorkspaces: { + 'eclipse/che-theia/latest': '/devfiles/go/devworkspace-che-theia-latest.yaml', + 'eclipse/che-theia/next': '/devfiles/go/devworkspace-che-theia-next.yaml', + }, + self: '/devfiles/go/devfile.yaml', + }, + }, + ]; +} + +function getSchemaV1() { + return { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + title: 'Devfile object', + description: 'This schema describes the structure of the devfile object', + definitions: { + attributes: { + type: 'object', + additionalProperties: { + type: 'string', + }, + }, + selector: { + type: 'object', + additionalProperties: { + type: 'string', + }, + }, + }, + additionalProperties: false, + properties: {}, + }; +} + +function getSchemaV200() { + return { + description: + 'Devfile describes the structure of a cloud-native workspace and development environment.', + type: 'object', + title: 'Devfile schema - Version 2.0.0', + required: ['schemaVersion'], + properties: { + schemaVersion: { + description: 'Devfile schema version', + type: 'string', + pattern: '^2\\.0\\.0$', + }, + }, + additionalProperties: false, + }; +} + +function getSchemaV210() { + return { + description: + 'Devfile describes the structure of a cloud-native devworkspace and development environment.', + type: 'object', + title: 'Devfile schema - Version 2.1.0', + required: ['schemaVersion'], + properties: { + schemaVersion: { + description: 'Devfile schema version', + type: 'string', + pattern: '^2\\.1\\.0$', + }, + }, + additionalProperties: false, + }; +} + +function getSchemaV220() { + return { + description: + 'Devfile describes the structure of a cloud-native devworkspace and development environment.', + type: 'object', + title: 'Devfile schema - Version 2.2.0-alpha', + required: ['schemaVersion'], + properties: { + schemaVersion: { + description: 'Devfile schema version', + type: 'string', + pattern: + '^([2-9])\\.([0-9]+)\\.([0-9]+)(\\-[0-9a-z-]+(\\.[0-9a-z-]+)*)?(\\+[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?$', + }, + }, + additionalProperties: false, + }; +} diff --git a/packages/dashboard-frontend/src/store/DevfileRegistries/index.ts b/packages/dashboard-frontend/src/store/DevfileRegistries/index.ts index cdd014018..bcc7483bf 100644 --- a/packages/dashboard-frontend/src/store/DevfileRegistries/index.ts +++ b/packages/dashboard-frontend/src/store/DevfileRegistries/index.ts @@ -47,56 +47,70 @@ export interface State { filter: string; } -interface RequestRegistryMetadataAction { - type: 'REQUEST_REGISTRY_METADATA'; +export enum Type { + REQUEST_REGISTRY_METADATA = 'REQUEST_REGISTRY_METADATA', + RECEIVE_REGISTRY_METADATA = 'RECEIVE_REGISTRY_METADATA', + RECEIVE_REGISTRY_ERROR = 'RECEIVE_REGISTRY_ERROR', + REQUEST_DEVFILE = 'REQUEST_DEVFILE', + RECEIVE_DEVFILE = 'RECEIVE_DEVFILE', + REQUEST_SCHEMA = 'REQUEST_SCHEMA', + RECEIVE_SCHEMA = 'RECEIVE_SCHEMA', + RECEIVE_SCHEMA_ERROR = 'RECEIVE_SCHEMA_ERROR', + UPDATE_PREBUILT_TEMPLATES = 'UPDATE_PREBUILT_TEMPLATES', + SET_FILTER = 'SET_FILTER', + CLEAR_FILTER = 'CLEAR_FILTER', } -interface ReceiveRegistryMetadataAction { - type: 'RECEIVE_REGISTRY_METADATA'; +export interface RequestRegistryMetadataAction { + type: Type.REQUEST_REGISTRY_METADATA; +} + +export interface ReceiveRegistryMetadataAction { + type: Type.RECEIVE_REGISTRY_METADATA; url: string; metadata: che.DevfileMetaData[]; } -interface ReceiveRegistryErrorAction { - type: 'RECEIVE_REGISTRY_ERROR'; +export interface ReceiveRegistryErrorAction { + type: Type.RECEIVE_REGISTRY_ERROR; url: string; error: string; } -interface RequestDevfileAction { - type: 'REQUEST_DEVFILE'; +export interface RequestDevfileAction { + type: Type.REQUEST_DEVFILE; } -interface ReceiveDevfileAction { - type: 'RECEIVE_DEVFILE'; +export interface ReceiveDevfileAction { + type: Type.RECEIVE_DEVFILE; url: string; devfile: string; } -interface RequestSchemaAction { - type: 'REQUEST_SCHEMA'; +export interface RequestSchemaAction { + type: Type.REQUEST_SCHEMA; } -interface ReceiveSchemaAction { - type: 'RECEIVE_SCHEMA'; +export interface ReceiveSchemaAction { + type: Type.RECEIVE_SCHEMA; schema: any; } -interface ReceiveSchemaErrorAction { - type: 'RECEIVE_SCHEMA_ERROR'; +export interface ReceiveSchemaErrorAction { + type: Type.RECEIVE_SCHEMA_ERROR; error: string; } -interface SetFilterValue extends Action { - type: 'SET_FILTER'; +export interface SetFilterValue extends Action { + type: Type.SET_FILTER; value: string; } -interface ClearFilterValue extends Action { - type: 'CLEAR_FILTER'; +export interface ClearFilterValue extends Action { + type: Type.CLEAR_FILTER; } -type KnownAction = +export type KnownAction = | RequestRegistryMetadataAction | ReceiveRegistryMetadataAction | ReceiveRegistryErrorAction @@ -124,19 +138,23 @@ export const actionCreators: ActionCreators = { requestRegistriesMetadata: (registryUrls: string): AppThunk> => async (dispatch): Promise => { - dispatch({ type: 'REQUEST_REGISTRY_METADATA' }); + dispatch({ type: Type.REQUEST_REGISTRY_METADATA }); + const promises = registryUrls.split(' ').map(async url => { try { const metadata = await fetchRegistryMetadata(url); + if (!Array.isArray(metadata) || metadata.length === 0) { + return; + } dispatch({ - type: 'RECEIVE_REGISTRY_METADATA', + type: Type.RECEIVE_REGISTRY_METADATA, url, metadata, }); } catch (e) { const error = common.helpers.errors.getMessage(e); dispatch({ - type: 'RECEIVE_REGISTRY_ERROR', + type: Type.RECEIVE_REGISTRY_ERROR, url, error, }); @@ -154,10 +172,10 @@ export const actionCreators: ActionCreators = { requestDevfile: (url: string): AppThunk> => async (dispatch): Promise => { - dispatch({ type: 'REQUEST_DEVFILE' }); + dispatch({ type: Type.REQUEST_DEVFILE }); try { const devfile = await fetchDevfile(url); - dispatch({ type: 'RECEIVE_DEVFILE', devfile, url }); + dispatch({ type: Type.RECEIVE_DEVFILE, devfile, url }); return devfile; } catch (e) { throw new Error(`Failed to request a devfile from URL: ${url}, \n` + e); @@ -167,14 +185,14 @@ export const actionCreators: ActionCreators = { requestJsonSchema: (): AppThunk => async (dispatch, getState): Promise => { - dispatch({ type: 'REQUEST_SCHEMA' }); + dispatch({ type: Type.REQUEST_SCHEMA }); try { const state = getState(); const schemav1 = await WorkspaceClient.restApiClient.getDevfileSchema<{ [key: string]: any; }>('1.0.0'); const items = selectPlugins(state); - const components = schemav1?.properties ? schemav1.properties.components : undefined; + const components = schemav1?.properties?.components; if (components) { const mountSources = components.items.properties.mountSources; // mount sources is specific only for some of component types but always appears @@ -216,8 +234,8 @@ export const actionCreators: ActionCreators = { const cheDevworkspaceEnabled = isDevworkspacesEnabled(state.workspacesSettings.settings); if (cheDevworkspaceEnabled) { // This makes $ref resolve against the first schema, otherwise the yaml language server will report errors - const patchedJSONString = JSON.stringify(schemav1).replaceAll( - '#/definitions', + const patchedJSONString = JSON.stringify(schemav1).replace( + /#\/definitions/g, '#/oneOf/0/definitions', ); const parsedSchemaV1 = JSON.parse(patchedJSONString); @@ -232,7 +250,7 @@ export const actionCreators: ActionCreators = { } dispatch({ - type: 'RECEIVE_SCHEMA', + type: Type.RECEIVE_SCHEMA, schema, }); return schema; @@ -240,7 +258,7 @@ export const actionCreators: ActionCreators = { const errorMessage = 'Failed to request devfile JSON schema, reason: ' + common.helpers.errors.getMessage(e); dispatch({ - type: 'RECEIVE_SCHEMA_ERROR', + type: Type.RECEIVE_SCHEMA_ERROR, error: errorMessage, }); throw errorMessage; @@ -250,11 +268,11 @@ export const actionCreators: ActionCreators = { setFilter: (value: string): AppThunk => dispatch => { - dispatch({ type: 'SET_FILTER', value }); + dispatch({ type: Type.SET_FILTER, value }); }, clearFilter: (): AppThunk => dispatch => { - dispatch({ type: 'CLEAR_FILTER' }); + dispatch({ type: Type.CLEAR_FILTER }); }, }; @@ -277,21 +295,21 @@ export const reducer: Reducer = ( const action = incomingAction as KnownAction; switch (action.type) { - case 'REQUEST_REGISTRY_METADATA': + case Type.REQUEST_REGISTRY_METADATA: return createObject(state, { isLoading: true, registries: {}, }); - case 'REQUEST_SCHEMA': + case Type.REQUEST_SCHEMA: return createObject(state, { isLoading: true, schema: {}, }); - case 'REQUEST_DEVFILE': + case Type.REQUEST_DEVFILE: return createObject(state, { isLoading: true, }); - case 'RECEIVE_REGISTRY_METADATA': + case Type.RECEIVE_REGISTRY_METADATA: return createObject(state, { isLoading: false, registries: createObject(state.registries, { @@ -300,7 +318,7 @@ export const reducer: Reducer = ( }, }), }); - case 'RECEIVE_REGISTRY_ERROR': + case Type.RECEIVE_REGISTRY_ERROR: return createObject(state, { isLoading: false, registries: { @@ -309,7 +327,7 @@ export const reducer: Reducer = ( }, }, }); - case 'RECEIVE_DEVFILE': + case Type.RECEIVE_DEVFILE: return createObject(state, { isLoading: false, devfiles: { @@ -318,26 +336,26 @@ export const reducer: Reducer = ( }, }, }); - case 'RECEIVE_SCHEMA': + case Type.RECEIVE_SCHEMA: return createObject(state, { isLoading: false, schema: { schema: action.schema, }, }); - case 'RECEIVE_SCHEMA_ERROR': + case Type.RECEIVE_SCHEMA_ERROR: return createObject(state, { isLoading: false, schema: { error: action.error, }, }); - case 'SET_FILTER': { + case Type.SET_FILTER: { return createObject(state, { filter: action.value, }); } - case 'CLEAR_FILTER': { + case Type.CLEAR_FILTER: { return createObject(state, { filter: '', }); diff --git a/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/__tests__/index.spec.ts b/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/__tests__/index.spec.ts index a7f313a61..76a3e3bd0 100644 --- a/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/__tests__/index.spec.ts +++ b/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/__tests__/index.spec.ts @@ -260,7 +260,7 @@ describe('dwPlugins store', () => { expect(mockAxios.get).not.toHaveBeenCalled(); }); - it('should create REQUEST_DW_EDITOR and RECEIVE_DW__EDITOR_ERROR when missing plugin registry URL to fetch the editor', async () => { + it('should create REQUEST_DW_EDITOR and RECEIVE_DW_EDITOR_ERROR when missing plugin registry URL to fetch the editor', async () => { (mockAxios.get as jest.Mock).mockRejectedValueOnce({ isAxiosError: true, code: '500', diff --git a/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/selectors.ts b/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/selectors.ts index 564564899..bc930297e 100644 --- a/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/selectors.ts +++ b/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/selectors.ts @@ -28,7 +28,7 @@ export const selectDwPluginsList = createSelector( .filter(plugin => plugin) as devfileApi.Devfile[], ); -export const selectDwEditorsPluginsList = EDITOR_NAME => +export const selectDwEditorsPluginsList = (EDITOR_NAME?: string) => createSelector( selectState, state => @@ -44,6 +44,8 @@ export const selectDwEditorsPluginsList = EDITOR_NAME => }[], ); +export const selectDefaultEditor = createSelector(selectState, state => state.defaultEditorName); + export const selectDwDefaultEditorError = createSelector( selectState, state => state.defaultEditorError, diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/index.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/index.ts index 334c0984a..52c590b1c 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/index.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/index.ts @@ -24,7 +24,6 @@ import { DEVWORKSPACE_NEXT_START_ANNOTATION, IStatusUpdate, } from '../../../services/workspace-client/devworkspace/devWorkspaceClient'; -import { CheWorkspaceClient } from '../../../services/workspace-client/cheworkspace/cheWorkspaceClient'; import devfileApi, { isDevWorkspace } from '../../../services/devfileApi'; import { deleteLogs, mergeLogs } from '../logs'; import { getDefer, IDeferred } from '../../../services/helpers/deferred'; @@ -34,7 +33,7 @@ import { devWorkspaceKind } from '../../../services/devfileApi/devWorkspace'; import { WorkspaceAdapter } from '../../../services/workspace-adapter'; import { DEVWORKSPACE_UPDATING_TIMESTAMP_ANNOTATION } from '../../../services/devfileApi/devWorkspace/metadata'; import * as DwPluginsStore from '../../Plugins/devWorkspacePlugins'; -const cheWorkspaceClient = container.get(CheWorkspaceClient); +import { selectDefaultNamespace } from '../../InfrastructureNamespaces/selectors'; const devWorkspaceClient = container.get(DevWorkspaceClient); const onStatusChangeCallbacks = new Map void>(); @@ -140,6 +139,10 @@ export type ActionCreators = { pluginRegistryInternalUrl: string | undefined, attributes: { [key: string]: string }, ) => AppThunk>; + createWorkspaceFromResources: ( + devworkspace: devfileApi.DevWorkspace, + devworkspaceTemplate: devfileApi.DevWorkspaceTemplate, + ) => AppThunk>; deleteWorkspaceLogs: (workspaceId: string) => AppThunk; }; @@ -178,7 +181,8 @@ export const actionCreators: ActionCreators = { dispatch({ type: 'REQUEST_DEVWORKSPACE' }); try { - const defaultNamespace = await cheWorkspaceClient.getDefaultNamespace(); + const defaultKubernetesNamespace = selectDefaultNamespace(getState()); + const defaultNamespace = defaultKubernetesNamespace.name; const { workspaces, resourceVersion } = await devWorkspaceClient.getAllWorkspaces( defaultNamespace, ); @@ -417,6 +421,37 @@ export const actionCreators: ActionCreators = { } }, + createWorkspaceFromResources: + ( + devworkspace: devfileApi.DevWorkspace, + devworkspaceTemplate: devfileApi.DevWorkspaceTemplate, + ): AppThunk> => + async (dispatch, getState): Promise => { + const defaultKubernetesNamespace = selectDefaultNamespace(getState()); + const defaultNamespace = defaultKubernetesNamespace.name; + try { + const workspace = await devWorkspaceClient.createFromResources( + defaultNamespace, + devworkspace, + devworkspaceTemplate, + ); + + dispatch({ + type: 'ADD_DEVWORKSPACE', + workspace, + }); + } catch (e) { + const errorMessage = + 'Failed to create a new workspace from the devfile, reason: ' + + common.helpers.errors.getMessage(e); + dispatch({ + type: 'RECEIVE_DEVWORKSPACE_ERROR', + error: errorMessage, + }); + throw errorMessage; + } + }, + createWorkspaceFromDevfile: ( devfile: devfileApi.Devfile, @@ -461,7 +496,7 @@ export const actionCreators: ActionCreators = { try { // If the devworkspace doesn't have a namespace then we assign it to the default kubernetesNamespace const devWorkspaceDevfile = devfile as devfileApi.Devfile; - const defaultNamespace = await cheWorkspaceClient.getDefaultNamespace(); + const defaultNamespace = selectDefaultNamespace(state); const dwEditorsList = selectDwEditorsPluginsList(cheEditor)(state); if (!devWorkspaceDevfile.metadata.attributes) { @@ -471,9 +506,9 @@ export const actionCreators: ActionCreators = { [DEVWORKSPACE_CHE_EDITOR]: cheEditor, }; - const workspace = await devWorkspaceClient.create( + const workspace = await devWorkspaceClient.createFromDevfile( devWorkspaceDevfile, - defaultNamespace, + defaultNamespace.name, dwEditorsList, pluginRegistryUrl, pluginRegistryInternalUrl, diff --git a/packages/dashboard-frontend/src/typings/che.d.ts b/packages/dashboard-frontend/src/typings/che.d.ts index 919998f9c..fb25c14bf 100755 --- a/packages/dashboard-frontend/src/typings/che.d.ts +++ b/packages/dashboard-frontend/src/typings/che.d.ts @@ -154,7 +154,14 @@ declare namespace che { globalMemoryLimit?: string; registry?: string; icon: string; - links: any; + links: { + v2?: string; + devWorkspaces?: { + [editorId: string]: string; + }; + self: string; + [key: string]: any; + }; tags: Array; } diff --git a/packages/dashboard-frontend/tsconfig.json b/packages/dashboard-frontend/tsconfig.json index 966e50c9b..67140b3e7 100644 --- a/packages/dashboard-frontend/tsconfig.json +++ b/packages/dashboard-frontend/tsconfig.json @@ -18,7 +18,7 @@ "@eclipse-che/common": ["../common/src"] }, "outDir": "lib", - "rootDir": "src", + "rootDir": "src" }, "include": [ "src" @@ -26,9 +26,9 @@ "exclude": [ "node_modules", "lib", - "**/__tests__/*", + "**/__tests__/*" ], "references": [ - { "path": "../common" }, + { "path": "../common" } ] }