diff --git a/src/devfile/commandResolver.ts b/src/devfile/commandResolver.ts new file mode 100644 index 000000000..6212ad9aa --- /dev/null +++ b/src/devfile/commandResolver.ts @@ -0,0 +1,29 @@ +/*----------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + *-----------------------------------------------------------------------------------------------*/ +import { Command, Data } from '../odo/componentTypeDescription'; + +export class CommandResolver { + public static getCommand(devfile: Data, commandId: string): Command { + const command = devfile.commands.find( + (c) => c.id.toLowerCase() === commandId.toLowerCase(), + ); + + if (!command) { + throw new Error(`Command '${commandId}' not found`); + } + + return command; + } + + public static getAllCommandsMap(devfile: Data): Map { + const map = new Map(); + + for (const command of devfile.commands) { + map.set(command.id.toLowerCase(), command); + } + + return map; + } +} diff --git a/src/devfile/compositeCommand.ts b/src/devfile/compositeCommand.ts new file mode 100644 index 000000000..186d329d2 --- /dev/null +++ b/src/devfile/compositeCommand.ts @@ -0,0 +1,24 @@ +/*----------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + *-----------------------------------------------------------------------------------------------*/ + +import { ComponentWorkspaceFolder } from '../odo/workspace'; +import { Command } from '../odo/componentTypeDescription'; +import { DevfileCommandRunner } from './devfileCommandRunner'; + +export class CompositeCommand { + + public static async execute( + componentFolder: ComponentWorkspaceFolder, + command: Command, + ): Promise { + + for (const childId of command.composite.commands) { + await DevfileCommandRunner.execute( + componentFolder, + childId, + ); + } + } +} diff --git a/src/devfile/devfileCommandRunner.ts b/src/devfile/devfileCommandRunner.ts new file mode 100644 index 000000000..a555f787d --- /dev/null +++ b/src/devfile/devfileCommandRunner.ts @@ -0,0 +1,109 @@ +/*----------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + *-----------------------------------------------------------------------------------------------*/ + +import { ComponentWorkspaceFolder } from '../odo/workspace'; +import { Command } from '../odo/componentTypeDescription'; +import { CommandResolver } from './commandResolver'; +import { ExecCommandExecutor } from './execCommand'; + +export class DevfileCommandRunner { + + public static async execute( + componentFolder: ComponentWorkspaceFolder, + commandId: string, + ): Promise { + + const devfile = + componentFolder.component.devfileData.devfile; + + const command = + CommandResolver.getCommand( + devfile, + commandId, + ); + + await this.executeCommand( + componentFolder, + command, + ); + } + + private static async executeCommand( + componentFolder: ComponentWorkspaceFolder, + command: Command, + ): Promise { + + if (command.exec) { + + await ExecCommandExecutor.execute( + componentFolder, + command.exec, + ); + + return; + } + + if (command.composite) { + + const devfile = + componentFolder.component.devfileData.devfile; + + const commandMap = + CommandResolver.getAllCommandsMap( + devfile, + ); + + const children = + command.composite.commands.map(id => { + + const child = + commandMap.get( + id.toLowerCase(), + ); + + if (!child) { + throw new Error( + `Command '${id}' not found`, + ); + } + + return child; + }); + + const isParallel = + (command.composite as { + parallel?: boolean; + }).parallel === true; + + if (isParallel) { + + await Promise.all( + children.map(child => + this.executeCommand( + componentFolder, + child, + ), + ), + ); + + } else { + + for (const child of children) { + + await this.executeCommand( + componentFolder, + child, + ); + } + } + + return; + } + + throw new Error( + `Unsupported command '${command.id}'`, + ); + } +} diff --git a/src/devfile/execCommand.ts b/src/devfile/execCommand.ts new file mode 100644 index 000000000..4df5a82da --- /dev/null +++ b/src/devfile/execCommand.ts @@ -0,0 +1,48 @@ +/*----------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + *-----------------------------------------------------------------------------------------------*/ + +import { ComponentWorkspaceFolder } from '../odo/workspace'; +import { Exec } from '../odo/componentTypeDescription'; +import { VariableResolver } from './variableResolver'; +import { Oc } from '../oc/ocWrapper'; +import { CommandText } from '../base/command'; +import { OpenShiftTerminalManager } from '../webview/openshift-terminal/openShiftTerminal'; + +export class ExecCommandExecutor { + + public static async execute( + componentFolder: ComponentWorkspaceFolder, + exec: Exec, + ): Promise { + + const devfile = + componentFolder.component.devfileData.devfile; + + const resolvedExec = + VariableResolver.resolveExec( + devfile, + exec, + ); + + const componentName = + devfile.metadata.name; + + const podName = + await Oc.Instance.getComponentPod( + componentName, + ); + + const command = new CommandText( + 'oc', + `exec ${podName} -c ${resolvedExec.component} -- sh -c "cd ${resolvedExec.workingDir} && ${resolvedExec.commandLine}"`, + ); + + void OpenShiftTerminalManager.getInstance().createTerminal( + command, + `Component ${componentName}: Run '${resolvedExec.commandLine}' Command`, + componentFolder.contextPath + ); + } +} diff --git a/src/devfile/parallelCompositeCommand.ts b/src/devfile/parallelCompositeCommand.ts new file mode 100644 index 000000000..d03c67bad --- /dev/null +++ b/src/devfile/parallelCompositeCommand.ts @@ -0,0 +1,27 @@ +/*----------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + *-----------------------------------------------------------------------------------------------*/ + +import { ComponentWorkspaceFolder } from 'src/odo/workspace'; +import { Command } from '../odo/componentTypeDescription'; +import { DevfileCommandRunner } from './devfileCommandRunner'; + +export class ParallelCompositeCommand { + + public static async execute( + componentFolder: ComponentWorkspaceFolder, + command: Command, + ): Promise { + + await Promise.all( + command.composite.commands.map( + childId => + DevfileCommandRunner.execute( + componentFolder, + childId, + ), + ), + ); + } +} diff --git a/src/devfile/variableResolver.ts b/src/devfile/variableResolver.ts new file mode 100644 index 000000000..f0778e8ce --- /dev/null +++ b/src/devfile/variableResolver.ts @@ -0,0 +1,68 @@ +/*----------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + *-----------------------------------------------------------------------------------------------*/ + +import { Container, Data, Exec } from '../odo/componentTypeDescription'; + +export class VariableResolver { + private static readonly VARIABLE_REGEX = /\$\{([^}]+)\}/g; + + public static resolveExec(devfile: Data, exec: Exec): Exec { + return { + ...exec, + workingDir: this.resolveValue(devfile, exec.workingDir ?? '/projects', exec.component), + commandLine: this.resolveValue(devfile, exec.commandLine, exec.component), + }; + } + + public static resolveValue(devfile: Data, value: string, componentName?: string): string { + if (!value) { + return value; + } + + return value.replace(this.VARIABLE_REGEX, (_, variable) => + this.resolveVariable(devfile, variable, componentName), + ); + } + + private static resolveVariable( + devfile: Data, + variable: string, + componentName?: string, + ): string { + if (variable === 'PROJECT_SOURCE') { + return '/projects'; + } + + if (componentName) { + const component = devfile.components.find((c) => c.name === componentName); + + const container = component?.container; + + const envValue = this.findEnvValue(container, variable); + + if (envValue) { + return envValue; + } + } + + return process.env[variable] ?? `\${${variable}}`; + } + + private static findEnvValue( + container: Container | undefined, + variable: string, + ): string | undefined { + const env = ( + container as unknown as { + env?: { + name: string; + value: string; + }[]; + } + )?.env; + + return env?.find((e) => e.name === variable)?.value; + } +} diff --git a/src/oc/ocWrapper.ts b/src/oc/ocWrapper.ts index ae372e21b..a85077ed4 100644 --- a/src/oc/ocWrapper.ts +++ b/src/oc/ocWrapper.ts @@ -1131,4 +1131,32 @@ export class Oc { await this.deleteOdoFiles(componentPath, componentName); } + + public async getComponentPod(componentName: string): Promise { + + const selectors = [ + `app.kubernetes.io/instance=${componentName}`, + `app.kubernetes.io/component=${componentName}`, + `component=${componentName}`, + `app=${componentName}` + ]; + + for (const selector of selectors) { + + const pods = + await this.getKubernetesObjects( + 'pods', + undefined, + selector + ); + + if (pods.length > 0) { + return pods[0].metadata.name as string; + } + } + + throw new Error( + `No running pod found for component '${componentName}'` + ); + } } diff --git a/src/odo/command.ts b/src/odo/command.ts index 5761857a7..c33a76741 100644 --- a/src/odo/command.ts +++ b/src/odo/command.ts @@ -76,8 +76,4 @@ export class Command { } return cTxt; } - - static runComponentCommand(commandId : string): CommandText { - return new CommandText('odo', `run ${commandId}`); - } } diff --git a/src/odo/componentTypeDescription.ts b/src/odo/componentTypeDescription.ts index 65d727fdf..68d2beedf 100644 --- a/src/odo/componentTypeDescription.ts +++ b/src/odo/componentTypeDescription.ts @@ -164,6 +164,7 @@ export interface Exec { export type Composite = { commands: string[]; + parallel?: boolean; group: Group; } diff --git a/src/openshift/component.ts b/src/openshift/component.ts index db9a8c323..627421fc2 100644 --- a/src/openshift/component.ts +++ b/src/openshift/component.ts @@ -20,6 +20,7 @@ import { vsCommand, VsCommandError } from '../vscommand'; import CreateComponentLoader from '../webview/create-component/createComponentLoader'; import { OpenShiftTerminalApi, OpenShiftTerminalManager } from '../webview/openshift-terminal/openShiftTerminal'; import OpenShiftItem, { clusterRequired, projectRequired } from './openshiftItem'; +import { DevfileCommandRunner } from '../devfile/devfileCommandRunner'; function createStartDebuggerResult(language: string, message = '') { const result: any = new String(message); @@ -606,20 +607,28 @@ export class Component extends OpenShiftItem { } } - @vsCommand('openshift.component.commands.command.run', true) + @vsCommand('openshift.component.commands.command.run', true) static runComponentCommand(componentFolder: ComponentWorkspaceFolder): Promise { const componentName = componentFolder.component.devfileData.devfile.metadata.name; if ('getCommand' in componentFolder) { const componentCommand = (componentFolder).getCommand(); - const command = Command.runComponentCommand(componentCommand.id); - void OpenShiftTerminalManager.getInstance().createTerminal( - command, - `Component ${componentName}: Run '${componentCommand.id}' Command`, - componentFolder.contextPath, + + if (!componentCommand) { + void window.showErrorMessage( + `No Command found in Component '${componentName}'`, + ); + return; + } + + void DevfileCommandRunner.execute( + componentFolder, + componentCommand.id, ); } else { - void window.showErrorMessage(`No Command found in Component '${componentName}`); + void window.showErrorMessage( + `No Command found in Component '${componentName}'`, + ); } return; - } + } } diff --git a/test/integration/command.test.ts b/test/integration/command.test.ts index f8df57206..86bc4fe84 100644 --- a/test/integration/command.test.ts +++ b/test/integration/command.test.ts @@ -21,6 +21,8 @@ import { OdoPreference } from '../../src/odo/odoPreference'; import { Odo } from '../../src/odo/odoWrapper'; import { LoginUtil } from '../../src/util/loginUtil'; import { YAML_STRINGIFY_OPTIONS } from '../../src/util/utils'; +import { ComponentWorkspaceFolder } from 'src/odo/workspace'; +import { DevfileCommandRunner } from '../../src/devfile/devfileCommandRunner'; const ODO = Odo.Instance; @@ -277,34 +279,6 @@ suite('odo commands integration', function () { const helloWorldCommandOutput = 'Hello, World!'; const helloWorldCommandExecCommandLine = `echo "${helloWorldCommandOutput}"`; - async function runComponentCommandInTerminal(commandId: string, cwd?) : Promise { - let termOutput = ''; - let termError = ''; - const term = executeCommandInTerminal(Command.runComponentCommand(commandId), cwd, { - onOutput(data) { - termOutput = termOutput.concat(data); - }, - onError(data) { - termError = termError.concat(data); - } - }); - - let hopesLeft = 30; - let commandIdRunning = false; - do { - hopesLeft--; - await new Promise(resolve => setTimeout(resolve, 2000)); - commandIdRunning = termOutput.indexOf(helloWorldCommandOutput) >= -1; - } while (hopesLeft > 0 && !commandIdRunning); - if (!commandIdRunning) { - if (termError.trim().length > 0) { - fail(`Run Component Command failed: ${termError}`); - } - fail('Waiting for command to start executing is timed out'); - } - return term; - } - async function fixupDevFile(devfilePath: string): Promise { // Parse YAML into an Object, add: // @@ -353,7 +327,7 @@ suite('odo commands integration', function () { } test('runComponentCommand()', async function () { - await ODO.execute( + await ODO.execute( Command.createLocalComponent( componentType, '2.1.1', @@ -384,20 +358,40 @@ suite('odo commands integration', function () { } } if (!helloCommand) { - fail(`Command '${helloWorldCommandId}' doesn't exist in Component '${componentName}'`); + fail( + `Command '${helloWorldCommandId}' doesn't exist in Component '${componentName}'` + ); } - let devTerm : Terminal; - let runCommandTerm : Terminal; + let devTerm: Terminal; + try { - devTerm = await startDevInTerminal(componentLocation); - runCommandTerm = await runComponentCommandInTerminal(helloWorldCommandId, componentLocation); + + devTerm = + await startDevInTerminal( + componentLocation + ); + + const componentFolder: ComponentWorkspaceFolder = { + contextPath: componentLocation, + component: componentDescription + }; + + await DevfileCommandRunner.execute( + componentFolder, + helloWorldCommandId + ); + + } catch (err) { + + fail( + err instanceof Error + ? err.message + : String(err) + ); + } finally { - // we instruct the pseudo terminals to close the dev session when any text is sent - if (runCommandTerm) { - runCommandTerm.sendText('exit'); - runCommandTerm.dispose(); - } + if (devTerm) { devTerm.sendText('exit'); devTerm.dispose(); diff --git a/test/unit/openshift/component.test.ts b/test/unit/openshift/component.test.ts index 7fdeb3bf5..eae2d1c1b 100644 --- a/test/unit/openshift/component.test.ts +++ b/test/unit/openshift/component.test.ts @@ -25,7 +25,7 @@ import * as openShiftComponent from '../../../src/openshift/component'; import { Util } from '../../../src/util/async'; import { Util as fsp } from '../../../src/util/utils'; import { OpenShiftTerminalManager } from '../../../src/webview/openshift-terminal/openShiftTerminal'; -import { comp1Folder } from '../../fixtures'; +import { comp1Folder, comp2Folder } from '../../fixtures'; const { expect } = chai; chai.use(sinonChai); @@ -125,6 +125,92 @@ suite('OpenShift/Component', function () { devForwardedPorts: [] } }; + const componentItem2: ComponentWorkspaceFolder = { + contextPath: comp2Folder, + component: { + devfilePath: `${path.join(fixtureFolder, 'components', 'comp2', 'devfile.yaml')}`, + devfileData: { + devfile: { + schemaVersion: '2.1.0', + metadata: { + name: 'comp2', + version: '2.0.1', + displayName: 'React', + description: 'React is a free and open-source front-end JavaScript library for building user interfaces based on UI components. It is maintained by Meta and a community of individual developers and companies.', + tags: [ + 'Node.js', + 'React' + ], + icon: 'https://raw.githubusercontent.com/devfile-samples/devfile-stack-icons/main/react.svg', + projectType: 'React', + language: 'Typescript', + }, + parent: null, + starterProjects: [ + { + name: 'nodejs-react-starter' + } + ], + components: [ + { + name: 'runtime', + container: { + image: 'registry.access.redhat.com/ubi8/nodejs-16:latest', + memoryLimit: '1024Mi', + endpoints: [ + { + name: 'http-react', + targetPort: 3000 + } + ], + mountSources: false, + volumeMounts: [], + } + } + ], + commands: [ + { + id: 'install', + exec: { + group: { + kind: 'build', + isDefault: true + }, + commandLine: 'npm install', + component: 'runtime', + workingDir: '${PROJECT_SOURCE}' + } + }, + { + id: 'run', + exec: { + group: { + kind: 'run', + isDefault: true + }, + commandLine: 'npm run dev', + component: 'runtime', + workingDir: '${PROJECT_SOURCE}' + } + } + ], + events: { + postStart: [] + } + }, + commands: [], + supportedOdoFeatures: { + debug: true, + deploy: true, + dev: true + } + }, + runningIn: null, + runningOn: null, + managedBy: 'odo', + devForwardedPorts: [] + } + }; let Component: typeof openShiftComponent.Component; let commandStub: sinon.SinonStub; @@ -178,28 +264,23 @@ suite('OpenShift/Component', function () { }); test('confirm delete', async function () { - const originalDevfile = await fs.readFile(componentItem1.component.devfilePath, 'utf-8'); - try { - showWarningMessageStub.resolves('Delete Configuration'); - await Component.deleteConfigurationFiles({ - component: { - name: 'comp1', - }, - contextPath: wsFolder1.uri.fsPath - } as unknown as ComponentWorkspaceFolder); + showWarningMessageStub.resolves('Delete Configuration'); + await Component.deleteConfigurationFiles({ + component: { + name: 'comp1', + }, + contextPath: wsFolder1.uri.fsPath + } as unknown as ComponentWorkspaceFolder); - expect(ocExecStub.called).to.be.true; + expect(ocExecStub.called).to.be.true; - const commands = ocExecStub.getCalls().map(call => - call.args[0].toString() - ); + const commands = ocExecStub.getCalls().map(call => + call.args[0].toString() + ); - expect(commands.some(cmd => - cmd.includes('oc') && cmd.includes('delete') - )).to.be.true; - } finally { - await fs.writeFile(componentItem1.component.devfilePath, originalDevfile, 'utf-8'); - } + expect(commands.some(cmd => + cmd.includes('oc') && cmd.includes('delete') + )).to.be.true; }); test('cancel delete', async function () { @@ -278,14 +359,14 @@ suite('OpenShift/Component', function () { test('calls the correct odo command', async function () { // As `odo describe` has removed, now we need to check the OpenShift terminal output // instead of stubbing 'executeInTerminal' expecting the command to be invoked on the terminal - const compName = componentItem1.component.devfileData.devfile.metadata.name + const compName = componentItem2.component.devfileData.devfile.metadata.name const writeStub = sinon.stub(); sinon.stub(OpenShiftTerminalManager, 'getInstance').returns({ writeToTerminal: writeStub } as any); - await Component.describe(componentItem1); + await Component.describe(componentItem2); const output = writeStub.firstCall.args[0]; expect(writeStub).calledOnce;