Files
azuredatastudio/extensions/notebook/src/jupyter/serverInstance.ts
Chris LaFreniere e72d0d03ed Notebooks: More Jupyter Server Hardening (#5264)
* Do not rely on same starting port every time, misc

* put back to 5 seconds for process kill timeout

* extHostNotebook shutdown manager handle
2019-04-30 14:13:04 -07:00

401 lines
14 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as UUID from 'vscode-languageclient/lib/utils/uuid';
import * as vscode from 'vscode';
import * as path from 'path';
import * as fs from 'fs-extra';
import * as os from 'os';
import { spawn, ExecOptions, SpawnOptions, ChildProcess } from 'child_process';
import * as nls from 'vscode-nls';
const localize = nls.loadMessageBundle();
import { IServerInstance } from './common';
import JupyterServerInstallation from './jupyterServerInstallation';
import * as utils from '../common/utils';
import * as constants from '../common/constants';
import * as notebookUtils from '../common/notebookUtils';
import * as ports from '../common/ports';
const NotebookConfigFilename = 'jupyter_notebook_config.py';
const CustomJsFilename = 'custom.js';
const defaultPort = 8888;
const JupyterStartedMessage = 'The Jupyter Notebook is running';
type MessageListener = (data: string | Buffer) => void;
type ErrorListener = (err: any) => void;
export interface IInstanceOptions {
/**
* The path to the initial document we want to start this server for
*/
documentPath: string;
/**
* Base install information needed in order to start the server instance
*/
install: JupyterServerInstallation;
/**
* Optional start directory for the notebook server. If none is set, will use a
* path relative to the initial document
*/
notebookDirectory?: string;
}
/**
* Helper class to enable testing without calling into file system or
* commandline shell APIs
*/
export class ServerInstanceUtils {
public mkDir(dirPath: string, outputChannel?: vscode.OutputChannel): Promise<void> {
return utils.mkDir(dirPath, outputChannel);
}
public removeDir(dirPath: string): Promise<void> {
return fs.remove(dirPath);
}
public pathExists(dirPath: string): Promise<boolean> {
return fs.pathExists(dirPath);
}
public copy(src: string, dest: string): Promise<void> {
return fs.copy(src, dest);
}
public existsSync(dirPath: string): boolean {
return fs.existsSync(dirPath);
}
public generateUuid(): string {
return UUID.generateUuid();
}
public executeBufferedCommand(cmd: string, options: ExecOptions, outputChannel?: vscode.OutputChannel): Thenable<string> {
return utils.executeBufferedCommand(cmd, options, outputChannel);
}
public spawn(command: string, args?: ReadonlyArray<string>, options?: SpawnOptions): ChildProcess {
return spawn(command, args, options);
}
public ensureProcessEnded(childProcess: ChildProcess): void {
if (!childProcess) {
return;
}
// Wait 5 seconds and then force kill. Jupyter stop is slow so this seems a reasonable time limit
setTimeout(() => {
// Test if the process is still alive. Throws an exception if not
try {
process.kill(childProcess.pid, 'SIGKILL');
} catch (error) {
if (!error || !error.code || (typeof error.code === 'string' && error.code !== 'ESRCH')) {
console.log(error);
}
}
}, 5000);
}
}
export class PerNotebookServerInstance implements IServerInstance {
/**
* Root of the jupyter directory structure. Config and data roots will be
* under this, in order to simplify deletion of folders on stop of the instance
*/
private baseDir: string;
/**
* Path to configuration folder for this instance. Typically:
* %extension_path%/jupyter_config/%server%_config
*/
private instanceConfigRoot: string;
/**
* Path to data folder for this instance. Typically:
* %extension_path%/jupyter_config/%server%_data
*/
private instanceDataRoot: string;
private _systemJupyterDir: string;
private _port: string;
private _uri: vscode.Uri;
private _isStarted: boolean = false;
private utils: ServerInstanceUtils;
private childProcess: ChildProcess;
private errorHandler: ErrorHandler = new ErrorHandler();
constructor(private options: IInstanceOptions, fsUtils?: ServerInstanceUtils) {
this.utils = fsUtils || new ServerInstanceUtils();
}
public get isStarted(): boolean {
return this._isStarted;
}
public get port(): string {
return this._port;
}
public get uri(): vscode.Uri {
return this._uri;
}
public async configure(): Promise<void> {
await this.configureJupyter();
}
public async start(): Promise<void> {
await this.startInternal();
}
public async stop(): Promise<void> {
try {
if (this.baseDir) {
let exists = await this.utils.pathExists(this.baseDir);
if (exists) {
await this.utils.removeDir(this.baseDir);
}
}
if (this.isStarted) {
let install = this.options.install;
let stopCommand = `"${install.pythonExecutable}" -m jupyter notebook stop ${this._port}`;
await this.utils.executeBufferedCommand(stopCommand, install.execOptions, install.outputChannel);
}
} catch (error) {
// For now, we don't care as this is non-critical
this.notify(this.options.install, localize('serverStopError', 'Error stopping Notebook Server: {0}', utils.getErrorMessage(error)));
} finally {
this._isStarted = false;
this.utils.ensureProcessEnded(this.childProcess);
this.handleConnectionClosed();
}
}
private async configureJupyter(): Promise<void> {
await this.createInstanceFolders();
let resourcesFolder = path.join(this.options.install.extensionPath, 'resources', constants.jupyterConfigRootFolder);
await this.copyInstanceConfig(resourcesFolder);
await this.CopyCustomJs(resourcesFolder);
await this.copyKernelsToSystemJupyterDirs();
}
private async createInstanceFolders(): Promise<void> {
this.baseDir = path.join(this.getSystemJupyterHomeDir(), 'instances', `${this.utils.generateUuid()}`);
this.instanceConfigRoot = path.join(this.baseDir, 'config');
this.instanceDataRoot = path.join(this.baseDir, 'data');
await this.utils.mkDir(this.baseDir, this.options.install.outputChannel);
await this.utils.mkDir(this.instanceConfigRoot, this.options.install.outputChannel);
await this.utils.mkDir(this.instanceDataRoot, this.options.install.outputChannel);
}
private async copyInstanceConfig(resourcesFolder: string): Promise<void> {
let configSource = path.join(resourcesFolder, NotebookConfigFilename);
let configDest = path.join(this.instanceConfigRoot, NotebookConfigFilename);
await this.utils.copy(configSource, configDest);
}
private async CopyCustomJs(resourcesFolder: string): Promise<void> {
let customPath = path.join(this.instanceConfigRoot, 'custom');
await this.utils.mkDir(customPath, this.options.install.outputChannel);
let customSource = path.join(resourcesFolder, CustomJsFilename);
let customDest = path.join(customPath, CustomJsFilename);
await this.utils.copy(customSource, customDest);
}
private async copyKernelsToSystemJupyterDirs(): Promise<void> {
let kernelsExtensionSource = path.join(this.options.install.extensionPath, 'kernels');
this._systemJupyterDir = path.join(this.getSystemJupyterHomeDir(), 'kernels');
if (!this.utils.existsSync(this._systemJupyterDir)) {
await this.utils.mkDir(this._systemJupyterDir, this.options.install.outputChannel);
}
await this.utils.copy(kernelsExtensionSource, this._systemJupyterDir);
}
private getSystemJupyterHomeDir(): string {
switch (process.platform) {
case 'win32':
let appDataWindows = process.env['APPDATA'];
return appDataWindows + '\\jupyter';
case 'darwin':
return path.resolve(os.homedir(), 'Library/Jupyter');
default:
return path.resolve(os.homedir(), '.local/share/jupyter');
}
}
/**
* Starts a Jupyter instance using the provided a start command. Server is determined to have
* started when the log message with URL to connect to is emitted.
*/
protected async startInternal(): Promise<void> {
if (this.isStarted) {
return;
}
let notebookDirectory = this.getNotebookDirectory();
// Find a port in a given range. If run into trouble, try another port inside the given range
let port = await ports.strictFindFreePort(new ports.StrictPortFindOptions(defaultPort, defaultPort + 1000));
let token = await notebookUtils.getRandomToken();
this._uri = vscode.Uri.parse(`http://localhost:${port}/?token=${token}`);
this._port = port.toString();
let startCommand = `"${this.options.install.pythonExecutable}" -m jupyter notebook --no-browser --notebook-dir "${notebookDirectory}" --port=${port} --NotebookApp.token=${token}`;
this.notifyStarting(this.options.install, startCommand);
// Execute the command
await this.executeStartCommand(startCommand);
}
private executeStartCommand(startCommand: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
let install = this.options.install;
this.childProcess = this.spawnJupyterProcess(install, startCommand);
let stdErrLog: string = '';
// Add listeners for the process exiting prematurely
let onErrorBeforeStartup = (err: any) => reject(err);
let onExitBeforeStart = (err: any) => {
if (!this.isStarted) {
reject(localize('notebookStartProcessExitPremature', 'Notebook process exited prematurely with error: {0}, StdErr Output: {1}', err, stdErrLog));
}
};
this.childProcess.on('error', onErrorBeforeStartup);
this.childProcess.on('exit', onExitBeforeStart);
// Add listener for the process to emit its web address
let handleStdout = (data: string | Buffer) => { install.outputChannel.appendLine(data.toString()); };
let handleStdErr = (data: string | Buffer) => {
// For some reason, URL info is sent on StdErr
let [url, port] = this.matchUrlAndPort(data);
if (url) {
// For now, will verify port matches
if (url.authority !== this._uri.authority
|| url.query !== this._uri.query) {
this._uri = url;
this._port = port;
}
this.notifyStarted(install, url.toString());
this._isStarted = true;
this.updateListeners(handleStdout, handleStdErr, onErrorBeforeStartup, onExitBeforeStart);
resolve();
} else {
stdErrLog += data.toString();
}
};
this.childProcess.stdout.on('data', handleStdout);
this.childProcess.stderr.on('data', handleStdErr);
});
}
private updateListeners(handleStdout: MessageListener, handleStdErr: MessageListener, onErrorBeforeStartup: ErrorListener, onExitBeforeStart: ErrorListener): void {
this.childProcess.stdout.removeListener('data', handleStdout);
this.childProcess.stderr.removeListener('data', handleStdErr);
this.childProcess.removeListener('error', onErrorBeforeStartup);
this.childProcess.removeListener('exit', onExitBeforeStart);
this.childProcess.addListener('error', this.handleConnectionError);
this.childProcess.addListener('exit', this.handleConnectionClosed);
process.addListener('exit', this.stop);
// TODO #897 covers serializing stdout and stderr to a location where we can read from so that user can see if they run into trouble
}
private handleConnectionError(error: Error): void {
let action = this.errorHandler.handleError(error);
if (action === ErrorAction.Shutdown) {
this.notify(this.options.install, localize('jupyterError', 'Error sent from Jupyter: {0}', utils.getErrorMessage(error)));
this.stop();
}
}
private handleConnectionClosed(): void {
this.childProcess = undefined;
this._isStarted = false;
}
getNotebookDirectory(): string {
if (this.options.notebookDirectory) {
if (this.options.notebookDirectory.endsWith('\\')) {
return this.options.notebookDirectory.substr(0, this.options.notebookDirectory.length - 1) + '/';
}
return this.options.notebookDirectory;
}
return path.dirname(this.options.documentPath);
}
private matchUrlAndPort(data: string | Buffer): [vscode.Uri, string] {
// regex: Looks for the successful startup log message like:
// [C 12:08:51.947 NotebookApp]
//
// Copy/paste this URL into your browser when you connect for the first time,
// to login with a token:
// http://localhost:8888/?token=f5ee846e9bd61c3a8d835ecd9b965591511a331417b997b7
let dataString = data.toString();
let urlMatch = dataString.match(/\[C[\s\S]+ {8}(.+:(\d+)\/.*)$/m);
if (urlMatch) {
// Legacy case: manually parse token info if no token/port were passed
return [vscode.Uri.parse(urlMatch[1]), urlMatch[2]];
} else if (this._uri && dataString.indexOf(JupyterStartedMessage) > -1) {
// Default case: detect the notebook started message, indicating our preferred port and token were used
return [this._uri, this._port];
}
return [undefined, undefined];
}
private notifyStarted(install: JupyterServerInstallation, jupyterUri: string): void {
install.outputChannel.appendLine(localize('jupyterOutputMsgStartSuccessful', '... Jupyter is running at {0}', jupyterUri));
}
private notify(install: JupyterServerInstallation, message: string): void {
install.outputChannel.appendLine(message);
}
private notifyStarting(install: JupyterServerInstallation, startCommand: string): void {
install.outputChannel.appendLine(localize('jupyterOutputMsgStart', '... Starting Notebook server'));
install.outputChannel.appendLine(` > ${startCommand}`);
}
private spawnJupyterProcess(install: JupyterServerInstallation, startCommand: string): ChildProcess {
// Specify the global environment variables.
// Note: Get env from the install since it gets used elsewhere
let env = this.getEnvWithConfigPaths(install.execOptions.env);
// 'MSHOST_TELEMETRY_ENABLED' and 'MSHOST_ENVIRONMENT' environment variables are set
// for telemetry purposes used by PROSE in the process where the Jupyter kernel runs
if (vscode.workspace.getConfiguration('telemetry').get<boolean>('enableTelemetry', true)) {
env['MSHOST_TELEMETRY_ENABLED'] = true;
} else {
env['MSHOST_TELEMETRY_ENABLED'] = false;
}
env['MSHOST_ENVIRONMENT'] = 'ADSClient-' + vscode.version;
// Start the notebook process
let options: SpawnOptions = {
shell: true,
env: env,
detached: false
};
let childProcess = this.utils.spawn(startCommand, [], options);
return childProcess;
}
private getEnvWithConfigPaths(env: {}): any {
let newEnv: { [key: string]: string } = Object.assign({}, env);
newEnv['JUPYTER_CONFIG_DIR'] = this.instanceConfigRoot;
newEnv['JUPYTER_PATH'] = this.instanceDataRoot;
return newEnv;
}
}
class ErrorHandler {
private numErrors: number = 0;
public handleError(error: Error): ErrorAction {
this.numErrors++;
return this.numErrors > 3 ? ErrorAction.Shutdown : ErrorAction.Continue;
}
}
enum ErrorAction {
Continue = 1,
Shutdown = 2
}