From 11a58b67e2822b67afe6118ba0198957037adab9 Mon Sep 17 00:00:00 2001 From: Matthew Pirocchi Date: Thu, 9 May 2019 16:45:51 -0700 Subject: [PATCH 1/8] Implement "Debug Locally" code lens for .NET Core. --- src/lambda/local/debugConfiguration.ts | 64 +++++- src/shared/codelens/csharpCodeLensProvider.ts | 205 ++++++++++++------ src/shared/codelens/localLambdaRunner.ts | 3 +- 3 files changed, 206 insertions(+), 66 deletions(-) diff --git a/src/lambda/local/debugConfiguration.ts b/src/lambda/local/debugConfiguration.ts index f0abfcdfa90..28047a3beac 100644 --- a/src/lambda/local/debugConfiguration.ts +++ b/src/lambda/local/debugConfiguration.ts @@ -5,10 +5,11 @@ 'use strict' +import * as os from 'os' import * as vscode from 'vscode' export interface DebugConfiguration extends vscode.DebugConfiguration { - readonly type: 'node' | 'python' + readonly type: 'node' | 'python' | 'coreclr' readonly request: 'attach' } @@ -33,3 +34,64 @@ export interface PythonDebugConfiguration extends DebugConfiguration { } ] } + +export interface DotNetCoreDebugConfiguration extends DebugConfiguration { + type: 'coreclr' + processId: string + pipeTransport: PipeTransport + windows: { + pipeTransport: PipeTransport + } + sourceFileMap: { + [key: string]: string + } +} + +export interface PipeTransport { + pipeProgram: 'sh' | 'powershell' + pipeArgs: string[] + debuggerPath: '/tmp/lambci_debug_files/vsdbg', + pipeCwd: string +} + +export function makeCoreCLRDebugConfiguration( + { codeUri, port }: { + port: number, + codeUri: string + } +): DotNetCoreDebugConfiguration { + const pipeArgs = [ + '-c', + `docker exec -i $(docker ps -q -f publish=${port}) \${debuggerCommand}` + ] + const debuggerPath = '/tmp/lambci_debug_files/vsdbg' + + if (os.platform() === 'win32') { + // Coerce drive letter to uppercase. While Windows is case-insensitive, sourceFileMap is case-sensitive. + codeUri = codeUri.replace(/^\w\:/, match => match.toUpperCase()) + } + + return { + name: '.NET Core Docker Attach', + type: 'coreclr', + request: 'attach', + processId: '1', + pipeTransport: { + pipeProgram: 'sh', + pipeArgs, + debuggerPath, + pipeCwd: codeUri + }, + windows: { + pipeTransport: { + pipeProgram: 'powershell', + pipeArgs, + debuggerPath, + pipeCwd: codeUri + } + }, + sourceFileMap: { + ['/var/task']: codeUri + } + } +} diff --git a/src/shared/codelens/csharpCodeLensProvider.ts b/src/shared/codelens/csharpCodeLensProvider.ts index 804aa78453e..4a18b1424c0 100644 --- a/src/shared/codelens/csharpCodeLensProvider.ts +++ b/src/shared/codelens/csharpCodeLensProvider.ts @@ -5,9 +5,14 @@ 'use strict' +import * as crossSpawn from 'cross-spawn' +import * as del from 'del' +import * as fs from 'fs' import * as path from 'path' +import * as util from 'util' import * as vscode from 'vscode' +import { makeCoreCLRDebugConfiguration } from '../../lambda/local/debugConfiguration' import { CloudFormation } from '../cloudformation/cloudformation' import { LambdaHandlerCandidate } from '../lambdaHandlerSearch' import { getLogger } from '../logger' @@ -21,7 +26,7 @@ import { Datum } from '../telemetry/telemetryEvent' import { TelemetryService } from '../telemetry/telemetryService' import { registerCommand } from '../telemetry/telemetryUtils' import { dirnameWithTrailingSlash } from '../utilities/pathUtils' -import { getChannelLogger } from '../utilities/vsCodeUtils' +import { getChannelLogger, getDebugPort } from '../utilities/vsCodeUtils' import { CodeLensProviderParams, getInvokeCmdKey, @@ -30,15 +35,18 @@ import { } from './codeLensUtils' import { executeSamBuild, - getHandlerRelativePath, - getLambdaInfoFromExistingTemplate, - getRelativeFunctionHandler, + ExecuteSamBuildArguments, invokeLambdaFunction, + InvokeLambdaFunctionArguments, + InvokeLambdaFunctionContext, LambdaLocalInvokeParams, makeBuildDir, - makeInputTemplate + makeInputTemplate, } from './localLambdaRunner' +const access = util.promisify(fs.access) +const mkdir = util.promisify(fs.mkdir) + export const CSHARP_LANGUAGE = 'csharp' export const CSHARP_ALLFILES: vscode.DocumentFilter[] = [ { @@ -115,7 +123,6 @@ async function onLocalInvokeCommand({ handlerName: string }): Promise, }): Promise<{ datum: Datum }> { - const channelLogger = getChannelLogger(toolkitOutputChannel) const resource = await getResourceFromTemplate({ templatePath: lambdaLocalInvokeParams.samTemplate.fsPath, @@ -123,76 +130,89 @@ async function onLocalInvokeCommand({ }) const runtime = CloudFormation.getRuntime(resource) - // Switch over to the output channel so the user has feedback that we're getting things ready - channelLogger.channel.show(true) - - channelLogger.info( - 'AWS.output.sam.local.start', - 'Preparing to run {0} locally...', - lambdaLocalInvokeParams.handlerName - ) - try { - if (!lambdaLocalInvokeParams.isDebug) { - const baseBuildDir = await makeBuildDir() - const templateDir = path.dirname(lambdaLocalInvokeParams.samTemplate.fsPath) - const documentUri = lambdaLocalInvokeParams.document.uri - const handlerName = lambdaLocalInvokeParams.handlerName - - const handlerFileRelativePath = getHandlerRelativePath({ - codeRoot: templateDir, - filePath: documentUri.fsPath - }) + // Switch over to the output channel so the user has feedback that we're getting things ready + channelLogger.channel.show(true) + channelLogger.info( + 'AWS.output.sam.local.start', + 'Preparing to run {0} locally...', + lambdaLocalInvokeParams.handlerName + ) - const relativeFunctionHandler = getRelativeFunctionHandler({ - handlerName, - runtime, - handlerFileRelativePath - }) + const baseBuildDir = await makeBuildDir() + const codeDir = path.dirname(lambdaLocalInvokeParams.document.uri.fsPath) + const documentUri = lambdaLocalInvokeParams.document.uri + const handlerName = lambdaLocalInvokeParams.handlerName - const lambdaInfo = await getLambdaInfoFromExistingTemplate({ - workspaceUri: lambdaLocalInvokeParams.workspaceFolder.uri, - relativeOriginalFunctionHandler: relativeFunctionHandler - }) + const inputTemplatePath = await makeInputTemplate({ + baseBuildDir, + codeDir, + relativeFunctionHandler: handlerName, + runtime, + }) - const properties = lambdaInfo ? lambdaInfo.resource.Properties : undefined - const codeDir = properties ? path.join(templateDir, properties.CodeUri) : templateDir + const buildArgs: ExecuteSamBuildArguments = { + baseBuildDir, + channelLogger, + codeDir, + inputTemplatePath, + samProcessInvoker: processInvoker, + } + if (lambdaLocalInvokeParams.isDebug) { + buildArgs.environmentVariables = { + SAM_BUILD_MODE: 'debug' + } + } + const samTemplatePath: string = await executeSamBuild(buildArgs) + + const invokeArgs: InvokeLambdaFunctionArguments = { + baseBuildDir, + documentUri, + originalHandlerName: handlerName, + handlerName, + originalSamTemplatePath: inputTemplatePath, + samTemplatePath, + runtime, + } - const inputTemplatePath = await makeInputTemplate({ - baseBuildDir, - codeDir, - relativeFunctionHandler, - properties, - runtime - }) + const invokeContext: InvokeLambdaFunctionContext = { + channelLogger, + configuration, + taskInvoker, + telemetryService + } - const samTemplatePath: string = await executeSamBuild({ - baseBuildDir, - channelLogger, - codeDir, - inputTemplatePath, - samProcessInvoker: processInvoker, + if (!lambdaLocalInvokeParams.isDebug) { + await invokeLambdaFunction( + invokeArgs, + invokeContext + ) + } else { + const codeUri = path.join( + path.dirname(lambdaLocalInvokeParams.samTemplate.fsPath), + CloudFormation.getCodeUri(resource) + ) + const debuggerPath = await installDebugger({ + runtime, + codeUri + }) + const port = await getDebugPort() + const debugConfig = makeCoreCLRDebugConfiguration({ + port, + codeUri }) await invokeLambdaFunction( { - baseBuildDir, - documentUri, - originalHandlerName: handlerName, - handlerName, - originalSamTemplatePath: inputTemplatePath, - samTemplatePath, - runtime + ...invokeArgs, + debugArgs: { + debugConfig, + debugPort: port, + debuggerPath + } }, - { - channelLogger, - configuration, - taskInvoker, - telemetryService - } + invokeContext ) - } else { - vscode.window.showInformationMessage(`Local debug for ${runtime} is currently not implemented.`) } } catch (err) { const error = err as Error @@ -380,3 +400,60 @@ export function isPublicMethodSymbol( export function generateDotNetLambdaHandler(components: DotNetLambdaHandlerComponents): string { return `${components.assembly}::${components.namespace}.${components.class}::${components.method}` } + +export async function installDebugger({ + runtime, + codeUri +}: { + runtime: string, + codeUri: string +}): Promise { + const vsdbgPath = path.resolve(codeUri, '.vsdbg') + + try { + await access(vsdbgPath) + + // vsdbg is already installed. + return vsdbgPath + } catch { + // We could not access vsdbgPath. Swallow error and continue. + } + + try { + await mkdir(vsdbgPath) + + const process = crossSpawn( + 'docker', + [ + 'run', + '--rm', + '--mount', + `type=bind,src=${vsdbgPath},dst=/vsdbg`, + '--entrypoint', + 'bash', + `lambci/lambda:${runtime}`, + '-c', + '"curl -sSL https://aka.ms/getvsdbgsh | bash /dev/stdin -v latest -l /vsdbg"' + ], + { + windowsVerbatimArguments: true + } + ) + + await new Promise((resolve, reject) => { + process.once('close', (code, signal) => { + if (code === 0) { + resolve() + } else { + reject(signal) + } + }) + }) + } catch (err) { + // Clean up to avoid leaving a bad installation in the user's workspace. + await del(vsdbgPath, { force: true }) + throw err + } + + return vsdbgPath +} diff --git a/src/shared/codelens/localLambdaRunner.ts b/src/shared/codelens/localLambdaRunner.ts index 917f3fcefac..375cfbc0115 100644 --- a/src/shared/codelens/localLambdaRunner.ts +++ b/src/shared/codelens/localLambdaRunner.ts @@ -413,7 +413,8 @@ export async function executeSamBuild({ baseDir: codeDir, templatePath: inputTemplatePath, invoker: samProcessInvoker, - manifestPath + manifestPath, + environmentVariables } await new SamCliBuildInvocation(samCliArgs).execute() From a674976a3398841242322d6e4b69f773cafd882b Mon Sep 17 00:00:00 2001 From: Matthew Pirocchi Date: Mon, 20 May 2019 14:46:09 -0700 Subject: [PATCH 2/8] Address feedback from PR. --- src/lambda/local/debugConfiguration.ts | 14 +- src/shared/clients/dockerClient.ts | 84 +++++++++ src/shared/codelens/codeLensUtils.ts | 2 + src/shared/codelens/csharpCodeLensProvider.ts | 162 ++++++++++-------- src/shared/codelens/pythonCodeLensProvider.ts | 3 +- 5 files changed, 183 insertions(+), 82 deletions(-) create mode 100644 src/shared/clients/dockerClient.ts diff --git a/src/lambda/local/debugConfiguration.ts b/src/lambda/local/debugConfiguration.ts index 28047a3beac..b85d58d70ca 100644 --- a/src/lambda/local/debugConfiguration.ts +++ b/src/lambda/local/debugConfiguration.ts @@ -7,6 +7,9 @@ import * as os from 'os' import * as vscode from 'vscode' +import { DRIVE_LETTER_REGEX } from '../../shared/codelens/codeLensUtils' + +const DEBUGGER_PATH = '/tmp/lambci_debug_files/vsdbg' export interface DebugConfiguration extends vscode.DebugConfiguration { readonly type: 'node' | 'python' | 'coreclr' @@ -50,7 +53,7 @@ export interface DotNetCoreDebugConfiguration extends DebugConfiguration { export interface PipeTransport { pipeProgram: 'sh' | 'powershell' pipeArgs: string[] - debuggerPath: '/tmp/lambci_debug_files/vsdbg', + debuggerPath: typeof DEBUGGER_PATH, pipeCwd: string } @@ -64,29 +67,28 @@ export function makeCoreCLRDebugConfiguration( '-c', `docker exec -i $(docker ps -q -f publish=${port}) \${debuggerCommand}` ] - const debuggerPath = '/tmp/lambci_debug_files/vsdbg' if (os.platform() === 'win32') { // Coerce drive letter to uppercase. While Windows is case-insensitive, sourceFileMap is case-sensitive. - codeUri = codeUri.replace(/^\w\:/, match => match.toUpperCase()) + codeUri = codeUri.replace(DRIVE_LETTER_REGEX, match => match.toUpperCase()) } return { - name: '.NET Core Docker Attach', + name: 'SamLocalDebug', type: 'coreclr', request: 'attach', processId: '1', pipeTransport: { pipeProgram: 'sh', pipeArgs, - debuggerPath, + debuggerPath: DEBUGGER_PATH, pipeCwd: codeUri }, windows: { pipeTransport: { pipeProgram: 'powershell', pipeArgs, - debuggerPath, + debuggerPath: DEBUGGER_PATH, pipeCwd: codeUri } }, diff --git a/src/shared/clients/dockerClient.ts b/src/shared/clients/dockerClient.ts new file mode 100644 index 00000000000..3660b0d144c --- /dev/null +++ b/src/shared/clients/dockerClient.ts @@ -0,0 +1,84 @@ +/*! + * Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +'use strict' + +import * as crossSpawn from 'cross-spawn' + +export interface DockerClient { + invoke(args: DockerInvokeArgs): Promise +} + +export interface DockerInvokeArgs { + command: 'run' + image: string + removeOnExit?: boolean + mount?: { + type: 'bind', + source: string, + destination: string + } + entryPoint?: { + command: string + args: string[] + } +} + +// TODO: Replace with a library such as https://www.npmjs.com/package/node-docker-api. +export class DefaultDockerClient implements DockerClient { + public async invoke({ + command, + image, + removeOnExit, + mount, + entryPoint + }: DockerInvokeArgs): Promise { + const args: string[] = [ command, image ] + + if (removeOnExit) { + args.push('--rm') + } + + if (mount) { + args.push( + '--mount', + `type=${mount.type},src=${mount.source},dst=${mount.destination}` + ) + } + + if (entryPoint) { + args.push( + entryPoint.command, + ...entryPoint.args + ) + } + + const process = crossSpawn( + 'docker', + args, + { windowsVerbatimArguments: true } + ) + + await new Promise((resolve, reject) => { + process.once('close', (code, signal) => { + if (code === 0) { + resolve() + } else { + reject(new DockerError(code, signal, args)) + } + }) + }) + } +} + +export class DockerError extends Error { + public constructor( + public readonly code: number, + public readonly signal: string, + args: string[] + ) { + super(`Could not invoke docker. Code: ${code}, Signal: ${signal}, Arguments: [${args.join(', ')}]`) + } +} diff --git a/src/shared/codelens/codeLensUtils.ts b/src/shared/codelens/codeLensUtils.ts index 0fdf7c4b0ad..6df20f18907 100644 --- a/src/shared/codelens/codeLensUtils.ts +++ b/src/shared/codelens/codeLensUtils.ts @@ -38,6 +38,8 @@ interface MakeConfigureCodeLensParams { language: Language } +export const DRIVE_LETTER_REGEX = /^\w\:/ + export async function makeCodeLenses({ document, token, handlers, language }: { document: vscode.TextDocument, token: vscode.CancellationToken, diff --git a/src/shared/codelens/csharpCodeLensProvider.ts b/src/shared/codelens/csharpCodeLensProvider.ts index 4a18b1424c0..b96ccd58ab8 100644 --- a/src/shared/codelens/csharpCodeLensProvider.ts +++ b/src/shared/codelens/csharpCodeLensProvider.ts @@ -5,15 +5,14 @@ 'use strict' -import * as crossSpawn from 'cross-spawn' import * as del from 'del' -import * as fs from 'fs' import * as path from 'path' -import * as util from 'util' import * as vscode from 'vscode' import { makeCoreCLRDebugConfiguration } from '../../lambda/local/debugConfiguration' +import { DefaultDockerClient, DockerClient } from '../clients/dockerClient' import { CloudFormation } from '../cloudformation/cloudformation' +import { access, mkdir } from '../filesystem' import { LambdaHandlerCandidate } from '../lambdaHandlerSearch' import { getLogger } from '../logger' import { @@ -44,9 +43,6 @@ import { makeInputTemplate, } from './localLambdaRunner' -const access = util.promisify(fs.access) -const mkdir = util.promisify(fs.mkdir) - export const CSHARP_LANGUAGE = 'csharp' export const CSHARP_ALLFILES: vscode.DocumentFilter[] = [ { @@ -77,19 +73,33 @@ export async function initialize({ registerCommand({ command, callback: async (params: LambdaLocalInvokeParams): Promise<{ datum: Datum }> => { - return await onLocalInvokeCommand({ - commandName: command, - lambdaLocalInvokeParams: params, - configuration, - toolkitOutputChannel, - processInvoker, - taskInvoker, - telemetryService - }) + return await onLocalInvokeCommand( + { + commandName: command, + lambdaLocalInvokeParams: params, + configuration, + toolkitOutputChannel, + processInvoker, + taskInvoker, + telemetryService + } + ) }, }) } +export interface OnLocalInvokeCommandContext { + installDebugger(args: InstallDebuggerArgs): Promise +} + +class DefaultOnLocalInvokeCommandContext implements OnLocalInvokeCommandContext { + private readonly dockerClient: DockerClient = new DefaultDockerClient() + + public async installDebugger(args: InstallDebuggerArgs): Promise { + return await _installDebugger(args, { dockerClient: this.dockerClient }) + } +} + /** * The command that is run when user clicks on Run Local or Debug Local CodeLens * Accepts object containing the following params: @@ -101,28 +111,31 @@ export async function initialize({ * @param taskInvoker - SAM CLI Task invoker * @param telemetryService - Telemetry service for metrics */ -async function onLocalInvokeCommand({ - configuration, - toolkitOutputChannel, - commandName, - lambdaLocalInvokeParams, - processInvoker, - taskInvoker, - telemetryService, - getResourceFromTemplate = async _args => await CloudFormation.getResourceFromTemplate(_args), -}: { - configuration: SettingsConfiguration - toolkitOutputChannel: vscode.OutputChannel, - commandName: string, - lambdaLocalInvokeParams: LambdaLocalInvokeParams, - processInvoker: SamCliProcessInvoker, - taskInvoker: SamCliTaskInvoker, - telemetryService: TelemetryService, - getResourceFromTemplate?(args: { - templatePath: string, - handlerName: string - }): Promise, -}): Promise<{ datum: Datum }> { +async function onLocalInvokeCommand( + { + configuration, + toolkitOutputChannel, + commandName, + lambdaLocalInvokeParams, + processInvoker, + taskInvoker, + telemetryService, + getResourceFromTemplate = async _args => await CloudFormation.getResourceFromTemplate(_args), + }: { + configuration: SettingsConfiguration + toolkitOutputChannel: vscode.OutputChannel, + commandName: string, + lambdaLocalInvokeParams: LambdaLocalInvokeParams, + processInvoker: SamCliProcessInvoker, + taskInvoker: SamCliTaskInvoker, + telemetryService: TelemetryService, + getResourceFromTemplate?(args: { + templatePath: string, + handlerName: string + }): Promise, + }, + { installDebugger }: OnLocalInvokeCommandContext = new DefaultOnLocalInvokeCommandContext() +): Promise<{ datum: Datum }> { const channelLogger = getChannelLogger(toolkitOutputChannel) const resource = await getResourceFromTemplate({ templatePath: lambdaLocalInvokeParams.samTemplate.fsPath, @@ -149,6 +162,7 @@ async function onLocalInvokeCommand({ codeDir, relativeFunctionHandler: handlerName, runtime, + properties: resource.Properties }) const buildArgs: ExecuteSamBuildArguments = { @@ -188,11 +202,14 @@ async function onLocalInvokeCommand({ invokeContext ) } else { - const codeUri = path.join( - path.dirname(lambdaLocalInvokeParams.samTemplate.fsPath), - CloudFormation.getCodeUri(resource) - ) - const debuggerPath = await installDebugger({ + const rawCodeUri = CloudFormation.getCodeUri(resource) + const codeUri = path.isAbsolute(rawCodeUri) ? + rawCodeUri : + path.join( + path.dirname(lambdaLocalInvokeParams.samTemplate.fsPath), + rawCodeUri + ) + const { debuggerPath } = await installDebugger({ runtime, codeUri }) @@ -401,53 +418,48 @@ export function generateDotNetLambdaHandler(components: DotNetLambdaHandlerCompo return `${components.assembly}::${components.namespace}.${components.class}::${components.method}` } -export async function installDebugger({ - runtime, - codeUri -}: { +interface InstallDebuggerArgs { runtime: string, codeUri: string -}): Promise { +} + +interface InstallDebuggerResult { + debuggerPath: string +} + +async function _installDebugger( + { runtime, codeUri }: InstallDebuggerArgs, + { dockerClient }: { dockerClient: DockerClient } +): Promise { const vsdbgPath = path.resolve(codeUri, '.vsdbg') try { await access(vsdbgPath) // vsdbg is already installed. - return vsdbgPath + return { debuggerPath: vsdbgPath } } catch { // We could not access vsdbgPath. Swallow error and continue. } try { await mkdir(vsdbgPath) - - const process = crossSpawn( - 'docker', - [ - 'run', - '--rm', - '--mount', - `type=bind,src=${vsdbgPath},dst=/vsdbg`, - '--entrypoint', - 'bash', - `lambci/lambda:${runtime}`, - '-c', - '"curl -sSL https://aka.ms/getvsdbgsh | bash /dev/stdin -v latest -l /vsdbg"' - ], - { - windowsVerbatimArguments: true + await dockerClient.invoke({ + command: 'run', + image: `lambci/lambda:${runtime}`, + removeOnExit: true, + mount: { + type: 'bind', + source: vsdbgPath, + destination: '/vsdbg' + }, + entryPoint: { + command: 'bash', + args: [ + '-c', + '"curl -sSL https://aka.ms/getvsdbgsh | bash /dev/stdin -v latest -l /vsdbg"' + ] } - ) - - await new Promise((resolve, reject) => { - process.once('close', (code, signal) => { - if (code === 0) { - resolve() - } else { - reject(signal) - } - }) }) } catch (err) { // Clean up to avoid leaving a bad installation in the user's workspace. @@ -455,5 +467,5 @@ export async function installDebugger({ throw err } - return vsdbgPath + return { debuggerPath: vsdbgPath } } diff --git a/src/shared/codelens/pythonCodeLensProvider.ts b/src/shared/codelens/pythonCodeLensProvider.ts index cf2f7babd62..b8c10e37f51 100644 --- a/src/shared/codelens/pythonCodeLensProvider.ts +++ b/src/shared/codelens/pythonCodeLensProvider.ts @@ -23,6 +23,7 @@ import { registerCommand } from '../telemetry/telemetryUtils' import { getChannelLogger, getDebugPort } from '../utilities/vsCodeUtils' import { CodeLensProviderParams, + DRIVE_LETTER_REGEX, getInvokeCmdKey, getMetricDatum, makeCodeLenses, @@ -155,7 +156,7 @@ def ${debugHandlerFunctionName}(event, context): const fixFilePathCapitalization = (filePath: string): string => { if (process.platform === 'win32') { - return filePath.replace(/^[a-z]/, match => match.toUpperCase()) + return filePath.replace(DRIVE_LETTER_REGEX, match => match.toUpperCase()) } return filePath From e2ae9625411486868c19b2947a1801cd72677c1b Mon Sep 17 00:00:00 2001 From: Matthew Pirocchi Date: Mon, 20 May 2019 15:57:25 -0700 Subject: [PATCH 3/8] Add tests for makeCoreCLRDebugConfiguration. --- src/lambda/local/debugConfiguration.ts | 10 +-- .../lambda/local/debugConfiguration.test.ts | 71 +++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 src/test/lambda/local/debugConfiguration.test.ts diff --git a/src/lambda/local/debugConfiguration.ts b/src/lambda/local/debugConfiguration.ts index b85d58d70ca..f129de59ed4 100644 --- a/src/lambda/local/debugConfiguration.ts +++ b/src/lambda/local/debugConfiguration.ts @@ -57,11 +57,13 @@ export interface PipeTransport { pipeCwd: string } +export interface MakeCoreCLRDebugConfigurationArguments { + port: number, + codeUri: string +} + export function makeCoreCLRDebugConfiguration( - { codeUri, port }: { - port: number, - codeUri: string - } + { codeUri, port }: MakeCoreCLRDebugConfigurationArguments ): DotNetCoreDebugConfiguration { const pipeArgs = [ '-c', diff --git a/src/test/lambda/local/debugConfiguration.test.ts b/src/test/lambda/local/debugConfiguration.test.ts new file mode 100644 index 00000000000..18fa55ac865 --- /dev/null +++ b/src/test/lambda/local/debugConfiguration.test.ts @@ -0,0 +1,71 @@ +/*! + * Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +'use strict' + +import * as assert from 'assert' +import * as os from 'os' +import * as path from 'path' + +import { + makeCoreCLRDebugConfiguration, + MakeCoreCLRDebugConfigurationArguments +} from '../../../lambda/local/debugConfiguration' + +describe('makeCoreCLRDebugConfiguration', async () => { + function makeConfig({ + codeUri = path.join('foo', 'bar'), + port = 42, + }: Partial) { + return makeCoreCLRDebugConfiguration({ codeUri, port }) + } + + it('uses the specified codeUri', async () => { + const config = makeConfig({}) + + assert.strictEqual( + config.sourceFileMap['/var/task'], + path.join('foo', 'bar') + ) + }) + + describe('windows', async () => { + if (os.platform() === 'win32') { + it('massages drive letters to uppercase', async () => { + const config = makeConfig({ codeUri: 'c:\\foo\\bar' }) + + assert.strictEqual( + config.windows.pipeTransport.pipeCwd, + 'C:\\foo\\bar' + ) + }) + } + + it('uses powershell', async () => { + const config = makeConfig({}) + + assert.strictEqual(config.windows.pipeTransport.pipeProgram, 'powershell') + }) + + it('uses the specified port', async () => { + const config = makeConfig({ port: 538 }) + + assert.strictEqual(config.windows.pipeTransport.pipeArgs.some(arg => arg.includes('538')), true) + }) + }) + describe('*nix', async () => { + it('uses the default shell', async () => { + const config = makeConfig({}) + + assert.strictEqual(config.pipeTransport.pipeProgram, 'sh') + }) + + it('uses the specified port', async () => { + const config = makeConfig({ port: 538 }) + + assert.strictEqual(config.pipeTransport.pipeArgs.some(arg => arg.includes('538')), true) + }) + }) +}) From 1ab0726fd5b448c49dc5998169881bc6023f8531 Mon Sep 17 00:00:00 2001 From: Matthew Pirocchi Date: Mon, 20 May 2019 17:08:36 -0700 Subject: [PATCH 4/8] Add tests for DefaultDockerClient. --- src/shared/clients/dockerClient.ts | 37 ++- src/test/shared/clients/dockerClient.test.ts | 232 +++++++++++++++++++ 2 files changed, 264 insertions(+), 5 deletions(-) create mode 100644 src/test/shared/clients/dockerClient.test.ts diff --git a/src/shared/clients/dockerClient.ts b/src/shared/clients/dockerClient.ts index 3660b0d144c..8b4355476bd 100644 --- a/src/shared/clients/dockerClient.ts +++ b/src/shared/clients/dockerClient.ts @@ -5,13 +5,14 @@ 'use strict' +import * as child_process from 'child_process' import * as crossSpawn from 'cross-spawn' export interface DockerClient { - invoke(args: DockerInvokeArgs): Promise + invoke(args: DockerInvokeArguments): Promise } -export interface DockerInvokeArgs { +export interface DockerInvokeArguments { command: 'run' image: string removeOnExit?: boolean @@ -26,15 +27,40 @@ export interface DockerInvokeArgs { } } +export interface Closeable { + onClose(callback: (code: number, signal: string, args?: string[]) => void): void +} + +export interface DockerInvokeContext { + spawn( + command: string, + args?: string[], + options?: child_process.SpawnOptions + ): Closeable +} + // TODO: Replace with a library such as https://www.npmjs.com/package/node-docker-api. export class DefaultDockerClient implements DockerClient { + + public constructor(private readonly context: DockerInvokeContext = { + spawn(command, args, options): Closeable { + const process = crossSpawn('docker', args, { windowsVerbatimArguments: true }) + + return { + onClose(callback: (code: number, signal: string, args?: string[]) => void): void { + process.once('close', (_code, _signal) => callback(_code, _signal, args)) + } + } + } + }) { } + public async invoke({ command, image, removeOnExit, mount, entryPoint - }: DockerInvokeArgs): Promise { + }: DockerInvokeArguments): Promise { const args: string[] = [ command, image ] if (removeOnExit) { @@ -50,19 +76,20 @@ export class DefaultDockerClient implements DockerClient { if (entryPoint) { args.push( + '--entrypoint', entryPoint.command, ...entryPoint.args ) } - const process = crossSpawn( + const process = this.context.spawn( 'docker', args, { windowsVerbatimArguments: true } ) await new Promise((resolve, reject) => { - process.once('close', (code, signal) => { + process.onClose((code, signal) => { if (code === 0) { resolve() } else { diff --git a/src/test/shared/clients/dockerClient.test.ts b/src/test/shared/clients/dockerClient.test.ts new file mode 100644 index 00000000000..91071df4e87 --- /dev/null +++ b/src/test/shared/clients/dockerClient.test.ts @@ -0,0 +1,232 @@ +/*! + * Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +'use strict' + +import * as assert from 'assert' +import * as path from 'path' + +import { + Closeable, + DefaultDockerClient, + DockerInvokeArguments +} from '../../../shared/clients/dockerClient' + +class MockCloseable implements Closeable { + private callback?: (code: number, signal: string, args?: string[]) => void + + public onClose(callback: (code: number, signal: string, args?: string[]) => void): void { + this.callback = callback + } + + public close(code: number, signal: string, args?: string[]) { + if (!this.callback) { + throw new Error('Callback not set') + } + + this.callback(code, signal, args) + } +} + +describe('DefaultDockerClient', async () => { + function makeInvokeArgs({ + command = 'run', + image = 'myimage', + ...rest + }: Partial): DockerInvokeArguments { + return { + command, + image, + ...rest + } + } + + describe('invoke', async () => { + it('uses the specified command', async () => { + let spawnCount = 0 + const closeable = new MockCloseable() + const client = new DefaultDockerClient({ + spawn(command, args, options): Closeable { + spawnCount++ + assert.ok(args) + assert.ok(args!.length) + assert.strictEqual(args![0], 'run') + + return closeable + } + }) + + const invokePromise = client.invoke(makeInvokeArgs({})) + closeable.close(0, 'mysignal') + await invokePromise + + assert.strictEqual(spawnCount, 1) + }) + + it('uses the specified image', async () => { + let spawnCount = 0 + const closeable = new MockCloseable() + const client = new DefaultDockerClient({ + spawn(command, args, options): Closeable { + spawnCount++ + assert.strictEqual(args && args.some(arg => arg === 'myimage'), true) + + return closeable + } + }) + + const invokePromise = client.invoke(makeInvokeArgs({})) + closeable.close(0, 'mysignal') + await invokePromise + + assert.strictEqual(spawnCount, 1) + }) + + it('includes the --rm flag if specified', async () => { + let spawnCount = 0 + const closeable = new MockCloseable() + const client = new DefaultDockerClient({ + spawn(command, args, options): Closeable { + spawnCount++ + assert.strictEqual(args && args.some(arg => arg === '--rm'), true) + + return closeable + } + }) + + const invokePromise = client.invoke(makeInvokeArgs({ + removeOnExit: true + })) + closeable.close(0, 'mysignal') + await invokePromise + + assert.strictEqual(spawnCount, 1) + }) + + it('includes the --mount flag if specified', async () => { + const source = path.join('my', 'src') + const destination = path.join('my', 'dst') + + let spawnCount = 0 + const closeable = new MockCloseable() + const client = new DefaultDockerClient({ + spawn(command, args, options): Closeable { + spawnCount++ + + assert.ok(args) + + const flagIndex = args!.findIndex(value => value === '--mount') + assert.notStrictEqual(flagIndex, -1) + + const flagValueIndex = flagIndex + 1 + assert.ok(flagValueIndex < args!.length) + assert.strictEqual( + args![flagValueIndex], + `type=bind,src=${source},dst=${destination}` + ) + + return closeable + } + }) + + const invokePromise = client.invoke(makeInvokeArgs({ + mount: { + type: 'bind', + source, + destination + } + })) + closeable.close(0, 'mysignal') + await invokePromise + + assert.strictEqual(spawnCount, 1) + }) + + it('includes the --entryPoint flag if specified', async () => { + const entryPointArgs = [ + 'myArg1', + 'myArg2' + ] + let spawnCount = 0 + const closeable = new MockCloseable() + const client = new DefaultDockerClient({ + spawn(command, args, options): Closeable { + spawnCount++ + + assert.ok(args) + + const flagIndex = args!.findIndex(value => value === '--entrypoint') + assert.notStrictEqual(flagIndex, -1) + + const flagCommandIndex = flagIndex + 1 + assert.ok(flagCommandIndex < args!.length) + assert.strictEqual( + args![flagCommandIndex], + 'mycommand' + ) + + const argsStartIndex = flagCommandIndex + 1 + entryPointArgs.forEach((value, index) => { + const argIndex = argsStartIndex + index + assert.ok(argIndex < args!.length) + assert.strictEqual(args![argIndex], value) + }) + + return closeable + } + }) + + const invokePromise = client.invoke(makeInvokeArgs({ + entryPoint: { + command: 'mycommand', + args: entryPointArgs + } + })) + closeable.close(0, 'mysignal') + await invokePromise + + assert.strictEqual(spawnCount, 1) + }) + + it('relies on PATH to locate docker', async () => { + let spawnCount = 0 + const closeable = new MockCloseable() + const client = new DefaultDockerClient({ + spawn(command, args, options): Closeable { + spawnCount++ + assert.strictEqual(command, 'docker') + + return closeable + } + }) + + const invokePromise = client.invoke(makeInvokeArgs({})) + closeable.close(0, 'mysignal') + await invokePromise + + assert.strictEqual(spawnCount, 1) + }) + + it('uses verbatim arguments on windows', async () => { + let spawnCount = 0 + const closeable = new MockCloseable() + const client = new DefaultDockerClient({ + spawn(command, args, options): Closeable { + spawnCount++ + assert.ok(options) + assert.strictEqual(options!.windowsVerbatimArguments, true) + + return closeable + } + }) + + const invokePromise = client.invoke(makeInvokeArgs({})) + closeable.close(0, 'mysignal') + await invokePromise + + assert.strictEqual(spawnCount, 1) + }) + }) +}) From 436ad33c7f9e792f1bd62e87f4dad60a29a79fc1 Mon Sep 17 00:00:00 2001 From: Matthew Pirocchi Date: Tue, 21 May 2019 17:08:27 -0700 Subject: [PATCH 5/8] Fix order of docker arguments. --- src/shared/clients/dockerClient.ts | 17 +++--- src/shared/codelens/csharpCodeLensProvider.ts | 58 +++++++++---------- 2 files changed, 35 insertions(+), 40 deletions(-) diff --git a/src/shared/clients/dockerClient.ts b/src/shared/clients/dockerClient.ts index 8b4355476bd..7a8f75a34d9 100644 --- a/src/shared/clients/dockerClient.ts +++ b/src/shared/clients/dockerClient.ts @@ -68,18 +68,17 @@ export class DefaultDockerClient implements DockerClient { } if (mount) { - args.push( - '--mount', - `type=${mount.type},src=${mount.source},dst=${mount.destination}` - ) + args.push('--mount', `type=${mount.type},src=${mount.source},dst=${mount.destination}`) } if (entryPoint) { - args.push( - '--entrypoint', - entryPoint.command, - ...entryPoint.args - ) + args.push('--entrypoint', entryPoint.command) + } + + args.push(image) + + if (entryPoint) { + args.push(...entryPoint.args) } const process = this.context.spawn( diff --git a/src/shared/codelens/csharpCodeLensProvider.ts b/src/shared/codelens/csharpCodeLensProvider.ts index b96ccd58ab8..f191fd09947 100644 --- a/src/shared/codelens/csharpCodeLensProvider.ts +++ b/src/shared/codelens/csharpCodeLensProvider.ts @@ -134,7 +134,7 @@ async function onLocalInvokeCommand( handlerName: string }): Promise, }, - { installDebugger }: OnLocalInvokeCommandContext = new DefaultOnLocalInvokeCommandContext() + context: OnLocalInvokeCommandContext = new DefaultOnLocalInvokeCommandContext() ): Promise<{ datum: Datum }> { const channelLogger = getChannelLogger(toolkitOutputChannel) const resource = await getResourceFromTemplate({ @@ -209,7 +209,7 @@ async function onLocalInvokeCommand( path.dirname(lambdaLocalInvokeParams.samTemplate.fsPath), rawCodeUri ) - const { debuggerPath } = await installDebugger({ + const { debuggerPath } = await context.installDebugger({ runtime, codeUri }) @@ -435,36 +435,32 @@ async function _installDebugger( try { await access(vsdbgPath) - - // vsdbg is already installed. - return { debuggerPath: vsdbgPath } } catch { - // We could not access vsdbgPath. Swallow error and continue. - } - - try { - await mkdir(vsdbgPath) - await dockerClient.invoke({ - command: 'run', - image: `lambci/lambda:${runtime}`, - removeOnExit: true, - mount: { - type: 'bind', - source: vsdbgPath, - destination: '/vsdbg' - }, - entryPoint: { - command: 'bash', - args: [ - '-c', - '"curl -sSL https://aka.ms/getvsdbgsh | bash /dev/stdin -v latest -l /vsdbg"' - ] - } - }) - } catch (err) { - // Clean up to avoid leaving a bad installation in the user's workspace. - await del(vsdbgPath, { force: true }) - throw err + // We could not access vsdbgPath, probably because it doesn't exist. + try { + await mkdir(vsdbgPath) + await dockerClient.invoke({ + command: 'run', + image: `lambci/lambda:${runtime}`, + removeOnExit: true, + mount: { + type: 'bind', + source: vsdbgPath, + destination: '/vsdbg' + }, + entryPoint: { + command: 'bash', + args: [ + '-c', + '"curl -sSL https://aka.ms/getvsdbgsh | bash /dev/stdin -v latest -l /vsdbg"' + ] + } + }) + } catch (err) { + // Clean up to avoid leaving a bad installation in the user's workspace. + await del(vsdbgPath, { force: true }) + throw err + } } return { debuggerPath: vsdbgPath } From 63e3b98572f8c05c6f4023dee9654d1fddc11c32 Mon Sep 17 00:00:00 2001 From: Matthew Pirocchi Date: Wed, 22 May 2019 12:43:57 -0700 Subject: [PATCH 6/8] Address feedback from PR. --- src/lambda/local/debugConfiguration.ts | 8 ++--- src/shared/clients/dockerClient.ts | 31 ++++++++++++------- src/shared/codelens/csharpCodeLensProvider.ts | 8 ++--- 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/src/lambda/local/debugConfiguration.ts b/src/lambda/local/debugConfiguration.ts index f129de59ed4..91fa2bd3480 100644 --- a/src/lambda/local/debugConfiguration.ts +++ b/src/lambda/local/debugConfiguration.ts @@ -9,7 +9,7 @@ import * as os from 'os' import * as vscode from 'vscode' import { DRIVE_LETTER_REGEX } from '../../shared/codelens/codeLensUtils' -const DEBUGGER_PATH = '/tmp/lambci_debug_files/vsdbg' +const DOTNET_CORE_DEBUGGER_PATH = '/tmp/lambci_debug_files/vsdbg' export interface DebugConfiguration extends vscode.DebugConfiguration { readonly type: 'node' | 'python' | 'coreclr' @@ -53,7 +53,7 @@ export interface DotNetCoreDebugConfiguration extends DebugConfiguration { export interface PipeTransport { pipeProgram: 'sh' | 'powershell' pipeArgs: string[] - debuggerPath: typeof DEBUGGER_PATH, + debuggerPath: typeof DOTNET_CORE_DEBUGGER_PATH, pipeCwd: string } @@ -83,14 +83,14 @@ export function makeCoreCLRDebugConfiguration( pipeTransport: { pipeProgram: 'sh', pipeArgs, - debuggerPath: DEBUGGER_PATH, + debuggerPath: DOTNET_CORE_DEBUGGER_PATH, pipeCwd: codeUri }, windows: { pipeTransport: { pipeProgram: 'powershell', pipeArgs, - debuggerPath: DEBUGGER_PATH, + debuggerPath: DOTNET_CORE_DEBUGGER_PATH, pipeCwd: codeUri } }, diff --git a/src/shared/clients/dockerClient.ts b/src/shared/clients/dockerClient.ts index 7a8f75a34d9..3e547754988 100644 --- a/src/shared/clients/dockerClient.ts +++ b/src/shared/clients/dockerClient.ts @@ -31,6 +31,17 @@ export interface Closeable { onClose(callback: (code: number, signal: string, args?: string[]) => void): void } +class CloseableChildProcess implements Closeable { + public constructor( + private readonly process: child_process.ChildProcess, + private readonly args?: string[] + ) { } + + public onClose(callback: (code: number, signal: string, args?: string[]) => void): void { + this.process.once('close', (_code, _signal) => callback(_code, _signal, this.args)) + } +} + export interface DockerInvokeContext { spawn( command: string, @@ -40,19 +51,17 @@ export interface DockerInvokeContext { } // TODO: Replace with a library such as https://www.npmjs.com/package/node-docker-api. -export class DefaultDockerClient implements DockerClient { +class DefaultDockerInvokeContext implements DockerInvokeContext { + public spawn(command: string, args?: string[], options?: child_process.SpawnOptions): Closeable { + const process = crossSpawn('docker', args, { windowsVerbatimArguments: true }) - public constructor(private readonly context: DockerInvokeContext = { - spawn(command, args, options): Closeable { - const process = crossSpawn('docker', args, { windowsVerbatimArguments: true }) + return new CloseableChildProcess(process, args) + } +} - return { - onClose(callback: (code: number, signal: string, args?: string[]) => void): void { - process.once('close', (_code, _signal) => callback(_code, _signal, args)) - } - } - } - }) { } +export class DefaultDockerClient implements DockerClient { + + public constructor(private readonly context: DockerInvokeContext = new DefaultDockerInvokeContext()) { } public async invoke({ command, diff --git a/src/shared/codelens/csharpCodeLensProvider.ts b/src/shared/codelens/csharpCodeLensProvider.ts index f191fd09947..33b5aa08463 100644 --- a/src/shared/codelens/csharpCodeLensProvider.ts +++ b/src/shared/codelens/csharpCodeLensProvider.ts @@ -211,7 +211,7 @@ async function onLocalInvokeCommand( ) const { debuggerPath } = await context.installDebugger({ runtime, - codeUri + targetFolder: codeUri }) const port = await getDebugPort() const debugConfig = makeCoreCLRDebugConfiguration({ @@ -420,7 +420,7 @@ export function generateDotNetLambdaHandler(components: DotNetLambdaHandlerCompo interface InstallDebuggerArgs { runtime: string, - codeUri: string + targetFolder: string } interface InstallDebuggerResult { @@ -428,10 +428,10 @@ interface InstallDebuggerResult { } async function _installDebugger( - { runtime, codeUri }: InstallDebuggerArgs, + { runtime, targetFolder }: InstallDebuggerArgs, { dockerClient }: { dockerClient: DockerClient } ): Promise { - const vsdbgPath = path.resolve(codeUri, '.vsdbg') + const vsdbgPath = path.resolve(targetFolder, '.vsdbg') try { await access(vsdbgPath) From d0c048a09ddd8fadafea4dadf013a958f712d568 Mon Sep 17 00:00:00 2001 From: Matthew Pirocchi Date: Wed, 22 May 2019 12:54:38 -0700 Subject: [PATCH 7/8] Fix failing test. --- src/shared/clients/dockerClient.ts | 2 +- src/test/shared/clients/dockerClient.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/shared/clients/dockerClient.ts b/src/shared/clients/dockerClient.ts index 3e547754988..88da661e867 100644 --- a/src/shared/clients/dockerClient.ts +++ b/src/shared/clients/dockerClient.ts @@ -70,7 +70,7 @@ export class DefaultDockerClient implements DockerClient { mount, entryPoint }: DockerInvokeArguments): Promise { - const args: string[] = [ command, image ] + const args: string[] = [ command ] if (removeOnExit) { args.push('--rm') diff --git a/src/test/shared/clients/dockerClient.test.ts b/src/test/shared/clients/dockerClient.test.ts index 91071df4e87..92b2ae7129a 100644 --- a/src/test/shared/clients/dockerClient.test.ts +++ b/src/test/shared/clients/dockerClient.test.ts @@ -167,9 +167,9 @@ describe('DefaultDockerClient', async () => { 'mycommand' ) - const argsStartIndex = flagCommandIndex + 1 - entryPointArgs.forEach((value, index) => { - const argIndex = argsStartIndex + index + const endIndex = (args!.length - 1) + entryPointArgs.reverse().forEach((value, index) => { + const argIndex = endIndex - index assert.ok(argIndex < args!.length) assert.strictEqual(args![argIndex], value) }) From a7fb3f6f9d62cec530939c8ee6e420cb967fe804 Mon Sep 17 00:00:00 2001 From: Matthew Pirocchi Date: Wed, 22 May 2019 13:25:36 -0700 Subject: [PATCH 8/8] Fix codelenses when source file is not a sibling of project file. --- src/shared/codelens/csharpCodeLensProvider.ts | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/shared/codelens/csharpCodeLensProvider.ts b/src/shared/codelens/csharpCodeLensProvider.ts index 33b5aa08463..0d17257c7fb 100644 --- a/src/shared/codelens/csharpCodeLensProvider.ts +++ b/src/shared/codelens/csharpCodeLensProvider.ts @@ -100,6 +100,17 @@ class DefaultOnLocalInvokeCommandContext implements OnLocalInvokeCommandContext } } +function getCodeUri(resource: CloudFormation.Resource, samTemplateUri: vscode.Uri) { + const rawCodeUri = CloudFormation.getCodeUri(resource) + + return path.isAbsolute(rawCodeUri) ? + rawCodeUri : + path.join( + path.dirname(samTemplateUri.fsPath), + rawCodeUri + ) +} + /** * The command that is run when user clicks on Run Local or Debug Local CodeLens * Accepts object containing the following params: @@ -153,13 +164,13 @@ async function onLocalInvokeCommand( ) const baseBuildDir = await makeBuildDir() - const codeDir = path.dirname(lambdaLocalInvokeParams.document.uri.fsPath) + const codeUri = getCodeUri(resource, lambdaLocalInvokeParams.samTemplate) const documentUri = lambdaLocalInvokeParams.document.uri const handlerName = lambdaLocalInvokeParams.handlerName const inputTemplatePath = await makeInputTemplate({ baseBuildDir, - codeDir, + codeDir: codeUri, relativeFunctionHandler: handlerName, runtime, properties: resource.Properties @@ -168,7 +179,7 @@ async function onLocalInvokeCommand( const buildArgs: ExecuteSamBuildArguments = { baseBuildDir, channelLogger, - codeDir, + codeDir: codeUri, inputTemplatePath, samProcessInvoker: processInvoker, } @@ -202,13 +213,6 @@ async function onLocalInvokeCommand( invokeContext ) } else { - const rawCodeUri = CloudFormation.getCodeUri(resource) - const codeUri = path.isAbsolute(rawCodeUri) ? - rawCodeUri : - path.join( - path.dirname(lambdaLocalInvokeParams.samTemplate.fsPath), - rawCodeUri - ) const { debuggerPath } = await context.installDebugger({ runtime, targetFolder: codeUri