diff --git a/appservice/package-lock.json b/appservice/package-lock.json index 60b6613b3f..9296415b8e 100644 --- a/appservice/package-lock.json +++ b/appservice/package-lock.json @@ -1718,7 +1718,8 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -1739,12 +1740,14 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1759,17 +1762,20 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -1886,7 +1892,8 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -1898,6 +1905,7 @@ "version": "1.0.0", "bundled": true, "dev": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -1912,6 +1920,7 @@ "version": "3.0.4", "bundled": true, "dev": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -1919,12 +1928,14 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "minipass": { "version": "2.3.5", "bundled": true, "dev": true, + "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -1943,6 +1954,7 @@ "version": "0.5.1", "bundled": true, "dev": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -2030,7 +2042,8 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -2042,6 +2055,7 @@ "version": "1.4.0", "bundled": true, "dev": true, + "optional": true, "requires": { "wrappy": "1" } @@ -2127,7 +2141,8 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -2163,6 +2178,7 @@ "version": "1.0.2", "bundled": true, "dev": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -2182,6 +2198,7 @@ "version": "3.0.1", "bundled": true, "dev": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -2225,12 +2242,14 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "yallist": { "version": "3.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true } } }, @@ -3752,6 +3771,40 @@ "pinkie": "^2.0.0" } }, + "portfinder": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.20.tgz", + "integrity": "sha512-Yxe4mTyDzTd59PZJY4ojZR8F+E5e97iq2ZOHPz3HDgSvYC5siNad2tLooQ5y5QHyQhc3xVqvyk/eNA3wuoa7Sw==", + "dev": true, + "requires": { + "async": "^1.5.2", + "debug": "^2.2.0", + "mkdirp": "0.5.x" + }, + "dependencies": { + "async": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", + "dev": true + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, "posix-character-classes": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", diff --git a/appservice/package.json b/appservice/package.json index f901fcfa54..4f2a80e678 100644 --- a/appservice/package.json +++ b/appservice/package.json @@ -60,6 +60,7 @@ "mocha": "^5.2.0", "mocha-junit-reporter": "^1.22.0", "mocha-multi-reporters": "^1.1.7", + "portfinder": "^1.0.20", "tslint": "^5.16.0", "tslint-microsoft-contrib": "5.0.1", "typescript": "^3.4.5", diff --git a/appservice/src/TunnelProxy.ts b/appservice/src/TunnelProxy.ts index 3910afddb0..489835d435 100644 --- a/appservice/src/TunnelProxy.ts +++ b/appservice/src/TunnelProxy.ts @@ -11,6 +11,7 @@ import * as requestP from 'request-promise'; import { IParsedError, parseError } from 'vscode-azureextensionui'; import * as websocket from 'websocket'; import { ext } from './extensionVariables'; +import { localize } from './localize'; import { SiteClient } from './SiteClient'; import { delay } from './utils/delay'; @@ -120,7 +121,7 @@ class TunnelSocket extends EventEmitter { /** * Interface for tunnel GetStatus API */ -enum WebAppState { +enum AppState { STARTED = 'STARTED', STARTING = 'STARTING', STOPPED = 'STOPPED' @@ -129,7 +130,7 @@ enum WebAppState { interface ITunnelStatus { port: number; canReachPort: boolean; - state: WebAppState; + state: AppState; msg: string; } @@ -171,26 +172,16 @@ export class TunnelProxy { this._server.unref(); } - // Starts up an app when it is found to be in the STOPPED state - // Apps can be in the STOPPED state for different reasons: - // 1. A stop request was sent through the Azure API (using the portal, using the extension, etc) - // - In this case it will respond with 403 until a start request is sent to the Azure API - // 2. The app is inactive, or was recently started - // - In this case it will stay stopped until a request is made to the app itself, waking it up - // - // To cover both cases, we send a start request followed by a ping to the app url + // Starts up an app by pinging it when it is found to be in the STOPPED state private async startupApp(): Promise { - ext.outputChannel.appendLine('[WebApp Tunnel] Sending start request...'); - await this._client.start(); - - ext.outputChannel.appendLine('[WebApp Tunnel] Pinging app default url...'); + ext.outputChannel.appendLine('[Tunnel] Pinging app default url...'); // tslint:disable-next-line:no-unsafe-any const pingResponse: IncomingMessage = await requestP.get({ uri: this._client.defaultHostUrl, simple: false, // allows the call to succeed without exception, even when status code is not 2XX resolveWithFullResponse: true // allows access to the status code from the response }); - ext.outputChannel.appendLine(`[WebApp Tunnel] Ping responded with status code: ${pingResponse.statusCode}`); + ext.outputChannel.appendLine(`[Tunnel] Ping responded with status code: ${pingResponse.statusCode}`); } private async checkTunnelStatus(): Promise { @@ -209,32 +200,32 @@ export class TunnelProxy { try { // tslint:disable-next-line:no-unsafe-any const responseBody: string = await requestP.get(statusOptions); - ext.outputChannel.appendLine(`[WebApp Tunnel] Checking status, body: ${responseBody}`); + ext.outputChannel.appendLine(`[Tunnel] Checking status, body: ${responseBody}`); // tslint:disable-next-line:no-unsafe-any tunnelStatus = JSON.parse(responseBody); } catch (error) { const parsedError: IParsedError = parseError(error); - ext.outputChannel.appendLine(`[WebApp Tunnel] Checking status, error: ${parsedError.message}`); - throw new Error(`Error getting tunnel status: ${parsedError.errorType}`); + ext.outputChannel.appendLine(`[Tunnel] Checking status, error: ${parsedError.message}`); + throw new Error(localize('tunnelStatusError', 'Error getting tunnel status: {0}', parsedError.errorType)); } - if (tunnelStatus.state === WebAppState.STARTED) { + if (tunnelStatus.state === AppState.STARTED) { if ((tunnelStatus.port === 2222 && !this._isSsh) || (tunnelStatus.port !== 2222 && this._isSsh)) { // Tunnel is pointed to default SSH port and still needs time to restart - throw new RetryableTunnelStatusError('WebApp is waiting for restart'); + throw new RetryableTunnelStatusError(); } else if (tunnelStatus.canReachPort) { return; } else { - throw new Error('WebApp is started, but port is unreachable'); + throw new Error(localize('tunnelUnreachable', 'App is started, but port is unreachable')); } - } else if (tunnelStatus.state === WebAppState.STARTING) { - throw new RetryableTunnelStatusError('WebApp is starting'); - } else if (tunnelStatus.state === WebAppState.STOPPED) { + } else if (tunnelStatus.state === AppState.STARTING) { + throw new RetryableTunnelStatusError(); + } else if (tunnelStatus.state === AppState.STOPPED) { await this.startupApp(); - throw new RetryableTunnelStatusError('WebApp is starting from STOPPED state'); + throw new RetryableTunnelStatusError(); } else { - throw new Error(`Unexpected WebApp state: ${tunnelStatus.state}`); + throw new Error(localize('tunnelStatusError', 'Unexpected app state: {0}', tunnelStatus.state)); } } @@ -252,14 +243,14 @@ export class TunnelProxy { return; } catch (error) { if (!(error instanceof RetryableTunnelStatusError)) { - reject(new Error(`Unable to establish connection to application: ${parseError(error).message}`)); + reject(new Error(localize('tunnelFailed', 'Unable to establish connection to application: {0}', parseError(error).message))); return; } // else allow retry } await delay(pollingIntervalMs); } - reject(new Error('Unable to establish connection to application: Timed out')); + reject(new Error(localize('tunnelTimedOut', 'Unable to establish connection to application: Timed out'))); }); } diff --git a/appservice/src/index.ts b/appservice/src/index.ts index 23bc1e4994..0feab0662f 100644 --- a/appservice/src/index.ts +++ b/appservice/src/index.ts @@ -13,6 +13,8 @@ export * from './createAppService/SiteHostingPlanStep'; export * from './createAppService/SiteNameStep'; export * from './createAppService/SiteOSStep'; export * from './createAppService/SiteRuntimeStep'; +export * from './remoteDebug/remoteDebugCommon'; +export * from './remoteDebug/startRemoteDebug'; export * from './createSlot'; export * from './deploy/deploy'; export * from './deploy/runPreDeployTask'; diff --git a/appservice/src/remoteDebug/remoteDebugCommon.ts b/appservice/src/remoteDebug/remoteDebugCommon.ts new file mode 100644 index 0000000000..6f4dbc6b75 --- /dev/null +++ b/appservice/src/remoteDebug/remoteDebugCommon.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SiteConfigResource } from 'azure-arm-website/lib/models'; +import * as vscode from 'vscode'; +import { callWithTelemetryAndErrorHandling, DialogResponses, IActionContext } from 'vscode-azureextensionui'; +import { ext } from '../extensionVariables'; +import { localize } from '../localize'; +import { SiteClient } from '../SiteClient'; + +export function reportMessage(message: string, progress: vscode.Progress<{}>): void { + ext.outputChannel.appendLine(message); + progress.report({ message: message }); +} + +export async function setRemoteDebug(isRemoteDebuggingToBeEnabled: boolean, confirmMessage: string, noopMessage: string | undefined, siteClient: SiteClient, siteConfig: SiteConfigResource, progress?: vscode.Progress<{}>, learnMoreLink?: string): Promise { + const state: string | undefined = await siteClient.getState(); + if (state && state.toLowerCase() === 'stopped') { + throw new Error(localize('remoteDebugStopped', 'The app must be running, but is currently in state "Stopped". Start the app to continue.')); + } + + if (isRemoteDebuggingToBeEnabled !== siteConfig.remoteDebuggingEnabled) { + const confirmButton: vscode.MessageItem = isRemoteDebuggingToBeEnabled ? { title: 'Enable' } : { title: 'Disable' }; + + // don't have to check input as this handles cancels and learnMore responses + await ext.ui.showWarningMessage(confirmMessage, { modal: true, learnMoreLink }, confirmButton, DialogResponses.cancel); + siteConfig.remoteDebuggingEnabled = isRemoteDebuggingToBeEnabled; + if (progress) { + reportMessage(localize('remoteDebugUpdate', 'Updating site configuration to set remote debugging...'), progress); + } + + await callWithTelemetryAndErrorHandling('appService.remoteDebugUpdateConfiguration', async (context: IActionContext) => { + context.errorHandling.suppressDisplay = true; + context.errorHandling.rethrow = true; + await siteClient.updateConfiguration(siteConfig); + }); + + if (progress) { + reportMessage(localize('remoteDebugUpdateDone', 'Updating site configuration done.'), progress); + } + } else { + // Update not needed + if (noopMessage) { + vscode.window.showWarningMessage(noopMessage); + } + } +} diff --git a/appservice/src/remoteDebug/startRemoteDebug.ts b/appservice/src/remoteDebug/startRemoteDebug.ts new file mode 100644 index 0000000000..b1097988b0 --- /dev/null +++ b/appservice/src/remoteDebug/startRemoteDebug.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SiteConfigResource, User } from 'azure-arm-website/lib/models'; +import * as portfinder from 'portfinder'; +import * as vscode from 'vscode'; +import { callWithTelemetryAndErrorHandling, IActionContext } from 'vscode-azureextensionui'; +import { ext } from '../extensionVariables'; +import { localize } from '../localize'; +import { SiteClient } from '../SiteClient'; +import { TunnelProxy } from '../TunnelProxy'; +import { reportMessage, setRemoteDebug } from './remoteDebugCommon'; + +const remoteDebugLink: string = 'https://aka.ms/appsvc-remotedebug'; + +let isRemoteDebugging: boolean = false; + +export async function startRemoteDebug(siteClient: SiteClient, siteConfig: SiteConfigResource): Promise { + if (isRemoteDebugging) { + throw new Error(localize('remoteDebugAlreadyStarted', 'Azure Remote Debugging is currently starting or already started.')); + } + + isRemoteDebugging = true; + try { + await startRemoteDebugInternal(siteClient, siteConfig); + } catch (error) { + isRemoteDebugging = false; + throw error; + } +} + +async function startRemoteDebugInternal(siteClient: SiteClient, siteConfig: SiteConfigResource): Promise { + await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification }, async (progress: vscode.Progress<{}>): Promise => { + const debugConfig: vscode.DebugConfiguration = await getDebugConfiguration(); + // tslint:disable-next-line:no-unsafe-any + const localHostPortNumber: number = debugConfig.port; + + const confirmEnableMessage: string = localize('remoteDebugEnablePrompt', 'The configuration will be updated to enable remote debugging. Would you like to continue? This will restart the app.'); + await setRemoteDebug(true, confirmEnableMessage, undefined, siteClient, siteConfig, progress, remoteDebugLink); + + reportMessage(localize('remoteDebugStartingTunnel', 'Starting tunnel proxy...'), progress); + + const publishCredential: User = await siteClient.getWebAppPublishCredential(); + const tunnelProxy: TunnelProxy = new TunnelProxy(localHostPortNumber, siteClient, publishCredential); + await callWithTelemetryAndErrorHandling('appService.remoteDebugStartProxy', async (startContext: IActionContext) => { + startContext.errorHandling.suppressDisplay = true; + startContext.errorHandling.rethrow = true; + await tunnelProxy.startProxy(); + }); + + reportMessage(localize('remoteDebugAttaching', 'Attaching debugger...'), progress); + + await callWithTelemetryAndErrorHandling('appService.remoteDebugAttach', async (attachContext: IActionContext) => { + attachContext.errorHandling.suppressDisplay = true; + attachContext.errorHandling.rethrow = true; + await vscode.debug.startDebugging(undefined, debugConfig); + }); + + reportMessage(localize('remoteDebugAttached', 'Attached!'), progress); + + const terminateDebugListener: vscode.Disposable = vscode.debug.onDidTerminateDebugSession(async (event: vscode.DebugSession) => { + if (event.name === debugConfig.name) { + isRemoteDebugging = false; + + if (tunnelProxy !== undefined) { + tunnelProxy.dispose(); + } + terminateDebugListener.dispose(); + + const confirmDisableMessage: string = localize('remoteDebugDisablePrompt', 'Remaining in debugging mode may cause performance issues. Would you like to disable debugging? This will restart the app.'); + await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification }, async (innerProgress: vscode.Progress<{}>): Promise => { + await setRemoteDebug(false, confirmDisableMessage, undefined, siteClient, siteConfig, innerProgress, remoteDebugLink); + }); + } + }); + }); +} + +async function getDebugConfiguration(): Promise { + const sessionId: string = Date.now().toString(); + const portNumber: number = await portfinder.getPortPromise(); + + // So far only node is supported + const config: vscode.DebugConfiguration = { + // return { + name: sessionId, + type: 'node', + protocol: 'inspector', + request: 'attach', + address: 'localhost', + port: portNumber + }; + + // Try to map workspace folder source files to the remote instance + if (vscode.workspace.workspaceFolders) { + if (vscode.workspace.workspaceFolders.length === 1) { + config.localRoot = vscode.workspace.workspaceFolders[0].uri.fsPath; + config.remoteRoot = '/home/site/wwwroot'; + } else { + // In this case we don't know which folder to use. Show a warning and proceed. + // In the future we should allow users to choose a workspace folder to map sources from. + // tslint:disable-next-line:no-floating-promises + ext.ui.showWarningMessage(localize('remoteDebugMultipleFolders', 'Unable to bind breakpoints from workspace when multiple folders are open. Use "loaded scripts" instead.')); + } + } else { + // vscode will throw an error if you try to start debugging without any workspace folder open + throw new Error(localize('remoteDebugNoFolders', 'Please open a workspace folder before attaching a debugger.')); + } + + return config; +}