diff --git a/Source/authenticator.ts b/Source/authenticator.ts index 30e1744..5599ddc 100644 --- a/Source/authenticator.ts +++ b/Source/authenticator.ts @@ -9,12 +9,16 @@ import { IAuthenticator } from "./types"; export class Authenticator implements IAuthenticator { constructor(private readonly fetch: SimpleFetch) {} + public async getJupyterAuthInfo( options: { baseUrl: string; + authInfo: { username: string; + password: string; + token: string; }; }, @@ -34,6 +38,7 @@ export class Authenticator implements IAuthenticator { return { tokenId: "", token: options.authInfo.password }; } } + if (options.authInfo.token) { const isApiTokenValid = await verifyApiToken( options.baseUrl, @@ -47,6 +52,7 @@ export class Authenticator implements IAuthenticator { return { tokenId: "", token: options.authInfo.token }; } } + return generateNewApiToken( options.baseUrl, options.authInfo.username, diff --git a/Source/common/async.ts b/Source/common/async.ts index 048f29f..5180cf9 100644 --- a/Source/common/async.ts +++ b/Source/common/async.ts @@ -8,8 +8,10 @@ export async function sleep(timeout: number, token?: CancellationToken) { const promise = new Promise((resolve) => { const timer = setTimeout(resolve, timeout); + disposables.push(new Disposable(() => clearTimeout(timer))); }); + await raceCancellation(token, promise).finally(() => { disposables.forEach((d) => d.dispose()); }); @@ -94,13 +96,16 @@ export async function raceCancellation( if (isPromiseLike(defaultValue)) { promises.push(defaultValue as unknown as Promise); + value = undefined; } else { value = defaultValue; } + if (!token) { return await Promise.race(promises); } + if (token.isCancellationRequested) { return value; } @@ -109,10 +114,13 @@ export async function raceCancellation( if (token.isCancellationRequested) { return resolve(value); } + const disposable = token.onCancellationRequested(() => { disposable.dispose(); + resolve(value); }); + Promise.race(promises) .then(resolve, reject) .finally(() => disposable.dispose()); @@ -125,6 +133,7 @@ export async function raceCancellationError( if (!token) { return Promise.race(promises); } + if (token.isCancellationRequested) { throw new CancellationError(); } @@ -133,10 +142,13 @@ export async function raceCancellationError( if (token.isCancellationRequested) { return reject(new CancellationError()); } + const disposable = token.onCancellationRequested(() => { disposable.dispose(); + reject(new CancellationError()); }); + Promise.race(promises) .then(resolve, reject) .finally(() => disposable.dispose()); @@ -149,10 +161,15 @@ export async function raceCancellationError( // eslint-disable-next-line @typescript-eslint/naming-convention export interface Deferred { readonly promise: Promise; + readonly resolved: boolean; + readonly rejected: boolean; + readonly completed: boolean; + readonly value?: T; + resolve(value?: T | PromiseLike): void; // eslint-disable-next-line @typescript-eslint/no-explicit-any reject(reason?: any): void; @@ -162,10 +179,15 @@ class DeferredImpl implements Deferred { private _resolve!: (value: T | PromiseLike) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any private _reject!: (reason?: any) => void; + private _resolved: boolean = false; + private _rejected: boolean = false; + private _promise: Promise; + private _value: T | undefined; + public get value() { return this._value; } @@ -174,30 +196,38 @@ class DeferredImpl implements Deferred { // eslint-disable-next-line this._promise = new Promise((res, rej) => { this._resolve = res; + this._reject = rej; }); } + public resolve(value?: T | PromiseLike) { this._value = value as T | undefined; // eslint-disable-next-line @typescript-eslint/no-explicit-any this._resolve.apply(this.scope ? this.scope : this, arguments as any); + this._resolved = true; } // eslint-disable-next-line @typescript-eslint/no-explicit-any public reject(_reason?: any) { // eslint-disable-next-line @typescript-eslint/no-explicit-any this._reject.apply(this.scope ? this.scope : this, arguments as any); + this._rejected = true; } + get promise(): Promise { return this._promise; } + get resolved(): boolean { return this._resolved; } + get rejected(): boolean { return this._rejected; } + get completed(): boolean { return this._rejected || this._resolved; } @@ -209,6 +239,7 @@ export function createDeferred(scope: any = null): Deferred { export function createDeferredFromPromise(promise: Promise): Deferred { const deferred = createDeferred(); + promise .then(deferred.resolve.bind(deferred)) .catch(deferred.reject.bind(deferred)); diff --git a/Source/common/crypto.ts b/Source/common/crypto.ts index ac0cf86..ab0692d 100644 --- a/Source/common/crypto.ts +++ b/Source/common/crypto.ts @@ -55,8 +55,10 @@ export async function computeHash( if (Object.keys(computedHashes).length > 10_000) { stopStoringHashes = true; } + computedHashes[data] = hash; } + return hash; } diff --git a/Source/common/inputCapture.ts b/Source/common/inputCapture.ts index 93f4956..a75340d 100644 --- a/Source/common/inputCapture.ts +++ b/Source/common/inputCapture.ts @@ -22,53 +22,77 @@ import { dispose } from "./lifecycle"; */ export class WorkflowInputCapture { private disposables: Disposable[] = []; + public dispose() { dispose(this.disposables); } + public async getValue( options: { title: string; + value?: string; + placeholder?: string; + validationMessage?: string; + password?: boolean; + validateInput?(value: string): Promise; + buttons?: QuickInputButton[]; + onDidTriggerButton?: (e: QuickInputButton) => void; }, token: CancellationToken, ) { return new Promise((resolve, reject) => { const input = window.createInputBox(); + this.disposables.push(new Disposable(() => input.hide())); + this.disposables.push(input); + input.ignoreFocusOut = true; + input.title = options.title; + input.ignoreFocusOut = true; + input.value = options.value || ""; + input.placeholder = options.placeholder || ""; + input.password = options.password === true; + input.validationMessage = options.validationMessage || ""; + input.buttons = [ QuickInputButtons.Back, ...(options.buttons || []), ]; + input.show(); + input.onDidChangeValue( () => (input.validationMessage = ""), this, this.disposables, ); + input.onDidTriggerButton( (e) => options.onDidTriggerButton?.(e), this, this.disposables, ); + input.onDidHide( () => reject(new CancellationError()), this, this.disposables, ); + input.onDidTriggerButton( (e) => { if (e === QuickInputButtons.Back) { @@ -78,6 +102,7 @@ export class WorkflowInputCapture { this, this.disposables, ); + input.onDidAccept( async () => { // Do not hide the input box, @@ -96,11 +121,13 @@ export class WorkflowInputCapture { // or display a new quick pick or ui. // Hence mark this as busy until we dismiss this UI. input.busy = true; + resolve(input.value || options.value || ""); }, this, this.disposables, ); + token.onCancellationRequested( () => reject(new CancellationError()), this, @@ -108,32 +135,48 @@ export class WorkflowInputCapture { ); }); } + public async pickValue( options: { title: string; + placeholder?: string; + validationMessage?: string; + quickPickItems: T[]; }, token: CancellationToken, ) { return new Promise((resolve, reject) => { const input = window.createQuickPick(); + this.disposables.push(new Disposable(() => input.hide())); + this.disposables.push(input); + input.ignoreFocusOut = true; + input.title = options.title; + input.ignoreFocusOut = true; + input.placeholder = options.placeholder || ""; + input.buttons = [QuickInputButtons.Back]; + input.items = options.quickPickItems; + input.canSelectMany = false; + input.show(); + input.onDidHide( () => reject(new CancellationError()), this, this.disposables, ); + input.onDidTriggerButton( (e) => { if (e === QuickInputButtons.Back) { @@ -143,6 +186,7 @@ export class WorkflowInputCapture { this, this.disposables, ); + input.onDidAccept( async () => { // After this we always end up doing some async stuff, @@ -159,6 +203,7 @@ export class WorkflowInputCapture { this, this.disposables, ); + token.onCancellationRequested( () => reject(new CancellationError()), this, @@ -175,38 +220,54 @@ export class WorkflowInputCapture { */ export class WorkflowQuickInputCapture { private disposables: Disposable[] = []; + private readonly _onDidTriggerItemButton = new EventEmitter< QuickPickItemButtonEvent >(); + readonly onDidTriggerItemButton = this._onDidTriggerItemButton.event; constructor() { this.disposables.push(this._onDidTriggerItemButton); } + public dispose() { dispose(this.disposables); } + public async getValue( options: { title: string; + placeholder?: string; + items: QuickPickItem[]; }, token: CancellationToken, ) { return new Promise((resolve, reject) => { const input = window.createQuickPick(); + this.disposables.push(input); + input.canSelectMany = false; + input.ignoreFocusOut = true; + input.placeholder = options.placeholder || ""; + input.title = options.title; + input.buttons = [QuickInputButtons.Back]; + input.items = options.items; + input.show(); + this.disposables.push( input.onDidHide(() => reject(new CancellationError())), ); + input.onDidTriggerButton( (e) => { if (e === QuickInputButtons.Back) { @@ -216,11 +277,13 @@ export class WorkflowQuickInputCapture { this, this.disposables, ); + input.onDidTriggerItemButton( (e) => this._onDidTriggerItemButton.fire(e), this, this.disposables, ); + input.onDidAccept( () => input.selectedItems.length @@ -229,6 +292,7 @@ export class WorkflowQuickInputCapture { this, this.disposables, ); + token.onCancellationRequested( () => reject(new CancellationError()), this, diff --git a/Source/common/lifecycle.ts b/Source/common/lifecycle.ts index 6c21512..a7caadb 100644 --- a/Source/common/lifecycle.ts +++ b/Source/common/lifecycle.ts @@ -54,11 +54,13 @@ export function dispose( export class DisposableStore { private readonly disposables: IDisposable[] = []; + add(disposable: T) { this.disposables.push(disposable); return disposable; } + dispose() { dispose(this.disposables); } diff --git a/Source/common/logging.ts b/Source/common/logging.ts index 7a5d8f9..e8a11c7 100644 --- a/Source/common/logging.ts +++ b/Source/common/logging.ts @@ -38,6 +38,7 @@ export function traceWarn(..._args: unknown[]): void { if (loggingLevel === "off") { return; } + logMessage("warn", ..._args); } @@ -45,6 +46,7 @@ export function traceError(..._args: unknown[]): void { if (loggingLevel === "off") { return; } + logMessage("error", ..._args); } @@ -52,6 +54,7 @@ export function traceDebug(..._args: unknown[]): void { if (loggingLevel !== "debug") { return; } + logMessage("debug", ..._args); } @@ -80,6 +83,7 @@ function formatErrors(...args: unknown[]) { if (!(arg instanceof Error)) { return arg; } + const info: string[] = [`${arg.name}: ${arg.message}`.trim()]; if (arg.stack) { @@ -98,7 +102,9 @@ function formatErrors(...args: unknown[]) { info.push(stack[0]); } } + const propertiesToIgnore = ["stack", "message", "name"]; + Object.keys(arg) .filter((key) => propertiesToIgnore.indexOf(key) === -1) // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/Source/common/request.ts b/Source/common/request.ts index 470efc0..f9391fd 100644 --- a/Source/common/request.ts +++ b/Source/common/request.ts @@ -52,6 +52,7 @@ export class SimpleFetch { if (value === Localized.jupyterSelfCertEnable) { solveCertificateProblem("self-signed", "allow"); + await workspace .getConfiguration("jupyter") .updateSetting( @@ -69,6 +70,7 @@ export class SimpleFetch { solveCertificateProblem("self-signed", "cancel"); } } + throw e; } } diff --git a/Source/common/requestCreator.node.ts b/Source/common/requestCreator.node.ts index 327d71d..4d24f5d 100644 --- a/Source/common/requestCreator.node.ts +++ b/Source/common/requestCreator.node.ts @@ -26,13 +26,16 @@ export class JupyterRequestCreator implements IJupyterRequestCreator { const authorizationHeader = getAuthHeader?.() || {}; const keys = Object.keys(authorizationHeader); + keys.forEach((k) => origHeaders.append(k, authorizationHeader[k].toString()), ); + origHeaders.set("Content-Type", "application/json"); // Rewrite the 'append' method for the headers to disallow 'authorization' after this point const origAppend = origHeaders.append.bind(origHeaders); + origHeaders.append = (k, v) => { if (k.toLowerCase() !== "authorization") { origAppend(k, v); diff --git a/Source/common/requestCreator.web.ts b/Source/common/requestCreator.web.ts index a2b1df4..46b7eac 100644 --- a/Source/common/requestCreator.web.ts +++ b/Source/common/requestCreator.web.ts @@ -19,16 +19,19 @@ export class JupyterRequestCreator implements IJupyterRequestCreator { const authorizationHeader = getAuthHeaders(); const keys = Object.keys(authorizationHeader); + keys.forEach((k) => origHeaders.append( k, authorizationHeader[k].toString(), ), ); + origHeaders.set("Content-Type", "application/json"); // Rewrite the 'append' method for the headers to disallow 'authorization' after this point const origAppend = origHeaders.append.bind(origHeaders); + origHeaders.append = (k, v) => { if (k.toLowerCase() !== "authorization") { origAppend(k, v); diff --git a/Source/common/stopwatch.ts b/Source/common/stopwatch.ts index 9ee031e..6f6cec3 100644 --- a/Source/common/stopwatch.ts +++ b/Source/common/stopwatch.ts @@ -6,9 +6,11 @@ */ export class StopWatch { private started = Date.now(); + public get elapsed() { return Date.now() - this.started; } + public reset() { this.started = Date.now(); } diff --git a/Source/common/telemetry.ts b/Source/common/telemetry.ts index 4ce9e88..2555a68 100644 --- a/Source/common/telemetry.ts +++ b/Source/common/telemetry.ts @@ -19,17 +19,25 @@ export interface IPropertyData { | "CustomerContent" | "PublicNonPersonalData" | "EndUserPseudonymizedInformation"; + purpose: "PerformanceAndHealth" | "FeatureInsight" | "BusinessInsight"; + comment: string; + expiration?: string; + endpoint?: string; + isMeasurement?: boolean; } export interface IGDPRProperty { owner: string; + comment: string; + expiration?: string; + readonly [name: string]: IPropertyData | undefined | IGDPRProperty | string; } @@ -81,6 +89,7 @@ export function publicLog2< telemetryReporter = telemetryReporter ? telemetryReporter : disposableStore.add(new TelemetryReporter(AppInsightsKey)); + telemetryReporter.sendTelemetryEvent(eventName, data); } @@ -95,31 +104,47 @@ function getHostName(url: string) { interface JupyterHubUrlAddedData { serverId: string; + hostNameHash: string; + baseUrlHash: string; + version: number; } type JupyterHubUrlDataClassification = { owner: "donjayamanne"; + comment: "Jupyter Hub Versions"; + serverId: { classification: "SystemMetaData"; + purpose: "FeatureInsight"; + comment: "unique identifier of server"; }; + hostNameHash: { classification: "SystemMetaData"; + purpose: "FeatureInsight"; + comment: "Hash of the host name of the server"; }; + baseUrlHash: { classification: "SystemMetaData"; + purpose: "FeatureInsight"; + comment: "Hash of the base url"; }; + version: { classification: "SystemMetaData"; + purpose: "FeatureInsight"; + comment: "Version of JupyterHub"; }; }; @@ -129,6 +154,7 @@ function stripPIIFromVersion(version: string) { if (parts.length < 2) { return 0; } + return parseFloat(`${parseInt(parts[0], 10)}.${parseInt(parts[1], 10)}`); } @@ -145,6 +171,7 @@ export function sendJupyterHubUrlAdded( serverId: string, ) { urlsAndVersion.set(baseUrl, version); + Promise.all([ getTelemetrySafeHashedString(getHostName(baseUrl)), getTelemetrySafeHashedString(baseUrl), @@ -165,7 +192,9 @@ export function sendJupyterHubUrlAdded( interface JupyterHubUrlNotAdded { failed: true; + reason: "cancel" | "back" | "error"; + lastStep: | "" | "Before" @@ -179,20 +208,30 @@ interface JupyterHubUrlNotAdded { } type JupyterHubUrlNotAddedClassification = { owner: "donjayamanne"; + comment: "Url was not added"; + failed: { classification: "SystemMetaData"; + purpose: "FeatureInsight"; + comment: "Indicator that adding the Url failed"; }; + reason: { classification: "SystemMetaData"; + purpose: "FeatureInsight"; + comment: "Reason for cancellation, back, cancel or error"; }; + lastStep: { classification: "SystemMetaData"; + purpose: "FeatureInsight"; + comment: "Last step the user took before exiting the workflow to add a url"; }; }; @@ -222,19 +261,27 @@ export function sendJupyterHubUrlNotAdded( interface JupyterHubTokenGeneratedUsingOldAPIData { hostNameHash: string; + baseUrlHash: string; } type JupyterHubTokenGeneratedUsingOldAPIDataClassification = { owner: "donjayamanne"; + comment: "Sent when we generate API tokens using the old API"; + hostNameHash: { classification: "SystemMetaData"; + purpose: "FeatureInsight"; + comment: "Hash of the host name of the server"; }; + baseUrlHash: { classification: "SystemMetaData"; + purpose: "FeatureInsight"; + comment: "Hash of the base url"; }; }; @@ -258,6 +305,7 @@ export function trackUsageOfOldApiGeneration(baseUrl: string) { interface JupyterHubUsage {} type JupyterHubUsageClassification = { owner: "donjayamanne"; + comment: "Sent extension activates"; }; @@ -267,19 +315,27 @@ export function trackInstallOfExtension() { interface JupyterHubUrlCertProblemsSolutionData { solution: "allow" | "cancel"; + problem: "self-signed" | "expired"; } type JupyterHubUrlCertProblemsSolutionDataClassification = { owner: "donjayamanne"; + comment: "Sent when user attempts to overcome a cert problem"; + problem: { classification: "SystemMetaData"; + purpose: "FeatureInsight"; + comment: "Problem with certificate"; }; + solution: { classification: "SystemMetaData"; + purpose: "FeatureInsight"; + comment: "How did user solve the cert problem did they allow usage of untrusted certs or cancel adding them"; }; }; diff --git a/Source/common/utils.ts b/Source/common/utils.ts index efa6be5..8411085 100644 --- a/Source/common/utils.ts +++ b/Source/common/utils.ts @@ -13,6 +13,7 @@ export function uuid() { for (var i = 0; i < 36; i++) { id[i] = chars.substring(Math.floor(Math.random() * 0x10))[0]; } + id[8] = id[13] = id[18] = id[23] = "-"; return id.join(""); diff --git a/Source/extension.node.ts b/Source/extension.node.ts index b25f013..26667c8 100644 --- a/Source/extension.node.ts +++ b/Source/extension.node.ts @@ -16,6 +16,7 @@ import { getJupyterApi } from "./utils"; export async function activate(context: ExtensionContext) { trackInstallOfExtension(); + context.subscriptions.push(disposableStore); getJupyterApi() @@ -34,6 +35,7 @@ export async function activate(context: ExtensionContext) { const uriCapture = disposableStore.add( new JupyterHubUrlCapture(fetch, storage), ); + disposableStore.add( new JupyterServerIntegration( fetch, diff --git a/Source/extension.web.ts b/Source/extension.web.ts index dfccae8..86a517b 100644 --- a/Source/extension.web.ts +++ b/Source/extension.web.ts @@ -18,6 +18,7 @@ export async function activate(context: ExtensionContext) { trackInstallOfExtension(); setIsWebExtension(); + context.subscriptions.push(disposableStore); getJupyterApi() @@ -36,6 +37,7 @@ export async function activate(context: ExtensionContext) { const uriCapture = disposableStore.add( new JupyterHubUrlCapture(fetch, storage), ); + disposableStore.add( new JupyterServerIntegration( fetch, diff --git a/Source/jupyterHubApi.ts b/Source/jupyterHubApi.ts index b7abe88..e5949be 100644 --- a/Source/jupyterHubApi.ts +++ b/Source/jupyterHubApi.ts @@ -19,11 +19,17 @@ export namespace ApiTypes { * The user's notebook server's base URL, if running; null if not. */ server?: string; + last_activity: Date; + roles: string[]; + groups: string[]; + name: string; + admin: boolean; + pending?: null | "spawn" | "stop"; /** * The servers for this user. By default: only includes active servers. @@ -31,6 +37,7 @@ export namespace ApiTypes { */ servers?: Record; } + export interface ServerInfo { /** * The server's name. @@ -111,6 +118,7 @@ export async function getVersion( return version; } + throw new Error("Non 200 response"); } catch (ex) { throw new Error( @@ -139,6 +147,7 @@ export async function deleteApiToken( method: "DELETE", headers: { Authorization: `token ${token}` }, }; + await fetch.send(url, options, cancellationToken); } @@ -177,7 +186,9 @@ export async function generateNewApiToken( auth: { username: username, password: password }, note: `Requested by JupyterHub extension in VSCode`, }; + type ResponseType = { user: string; id: string; token: string }; + response = await fetch.send( url, { method: "POST", body: JSON.stringify(body) }, @@ -185,6 +196,7 @@ export async function generateNewApiToken( ); const json = (await response.json()) as ResponseType; + traceDebug(`Generated new token for user using the new way`); return { token: json.token, tokenId: json.id }; @@ -223,6 +235,7 @@ export async function generateNewApiTokenOldWay( const url = appendUrlPath(baseUrl, `hub/api/authorizations/token`); const body = { username: username, password: password }; + type ResponseType = { user: {}; token: string }; const response = await fetch.send( @@ -235,10 +248,12 @@ export async function generateNewApiTokenOldWay( if (json.token) { traceDebug(`Generated new token for user using the old way`); + trackUsageOfOldApiGeneration(baseUrl); return { token: json.token, tokenId: "" }; } + throw new Error("Unable to generate Token using the old api route"); } catch (ex) { traceError(`Failed to generate token, trying old way`, ex); @@ -274,12 +289,14 @@ export async function getUserInfo( if (response.status === 200) { const json = await response.json(); + traceDebug( `Got user info for user ${baseUrl} = ${JSON.stringify(json)}`, ); return json; } + throw new Error( await getResponseErrorMessageToThrowOrLog( `Failed to get user info`, @@ -316,7 +333,9 @@ export async function getUserJupyterUrl( if (server?.url) { return appendUrlPath(baseUrl, server.url); } + const servers = Object.keys(info.servers || {}); + traceError( `Failed to get the user Jupyter Url for ${serverName} existing servers include ${JSON.stringify(info)}`, ); @@ -330,6 +349,7 @@ export async function getUserJupyterUrl( if (defaultServer) { return appendUrlPath(baseUrl, defaultServer); } + traceError( `Failed to get the user Jupyter Url as there is no default server for the user ${JSON.stringify(info)}`, ); @@ -399,6 +419,7 @@ export async function startServer( if (response.status === 201 || response.status === 202) { return; } + throw new Error( await getResponseErrorMessageToThrowOrLog( `Failed to fetch user info`, @@ -413,6 +434,7 @@ async function getResponseErrorMessageToThrowOrLog( if (!response) { return message; } + let responseText = ""; try { @@ -423,6 +445,7 @@ async function getResponseErrorMessageToThrowOrLog( ex, ); } + return `${message}, ${response.statusText} (${response.status}) with message ${responseText}`; } @@ -431,6 +454,7 @@ export async function createServerConnectSettings( serverName: string | undefined, authInfo: { username: string; + token: string; }, fetch: SimpleFetch, @@ -473,6 +497,7 @@ export async function createServerConnectSettings( fetch.requestCreator.createHttpRequestAgent ) { const requestAgent = fetch.requestCreator.createHttpRequestAgent(); + requestInit = { ...requestInit, agent: requestAgent }; } diff --git a/Source/jupyterIntegration.ts b/Source/jupyterIntegration.ts index 933839d..197a8d9 100644 --- a/Source/jupyterIntegration.ts +++ b/Source/jupyterIntegration.ts @@ -45,15 +45,23 @@ export class JupyterServerIntegration implements JupyterServerProvider, JupyterServerCommandProvider { readonly id: string = "UserJupyterServerPickerProviderId"; + readonly documentation = Uri.parse( "https://aka.ms/vscodeJuptyerExtKernelPickerExistingServer", ); + private readonly newAuthenticator: Authenticator; + private readonly disposables: Disposable[] = []; + private readonly _onDidChangeServers = new EventEmitter(); + public readonly onDidChangeServers = this._onDidChangeServers.event; + private previouslyEnteredUrlTypedIntoQuickPick?: string; + private previouslyEnteredJupyterServerBasedOnUrlTypedIntoQuickPick?: JupyterServer; + private readonly jupyterConnectionValidator: IJupyterHubConnectionValidator; constructor( @@ -66,6 +74,7 @@ export class JupyterServerIntegration this.jupyterConnectionValidator = new JupyterHubConnectionValidator( fetch, ); + this.newAuthenticator = new Authenticator(fetch); const collection = this.jupyterApi.createJupyterServerCollection( @@ -73,14 +82,20 @@ export class JupyterServerIntegration Localized.KernelActionSourceTitle, this, ); + this.disposables.push(collection); + this.disposables.push(this._onDidChangeServers); + collection.commandProvider = this; + collection.documentation = Uri.parse("https://aka.ms/vscodeJupyterHub"); } + public dispose() { dispose(this.disposables); } + public async handleCommand( command: JupyterServerCommand & { url?: string }, token: CancellationToken, @@ -104,15 +119,18 @@ export class JupyterServerIntegration this.previouslyEnteredJupyterServerBasedOnUrlTypedIntoQuickPick ) { whyCaptureUrl = "cameHereFromBackButton"; + serverId = this .previouslyEnteredJupyterServerBasedOnUrlTypedIntoQuickPick .id; + displayName = this .previouslyEnteredJupyterServerBasedOnUrlTypedIntoQuickPick .label; } + const server = await this.urlCapture.captureRemoteJupyterUrl( token, url, @@ -125,11 +143,14 @@ export class JupyterServerIntegration if (!server) { this.previouslyEnteredJupyterServerBasedOnUrlTypedIntoQuickPick = undefined; + this.previouslyEnteredUrlTypedIntoQuickPick = undefined; return; } + this._onDidChangeServers.fire(); + this.previouslyEnteredJupyterServerBasedOnUrlTypedIntoQuickPick = server; @@ -138,7 +159,9 @@ export class JupyterServerIntegration if (!(ex instanceof CancellationError)) { traceError(`Failed to select a Jupyter Server`, ex); } + this.previouslyEnteredUrlTypedIntoQuickPick = undefined; + this.previouslyEnteredJupyterServerBasedOnUrlTypedIntoQuickPick = undefined; @@ -154,6 +177,7 @@ export class JupyterServerIntegration ): Promise { this.previouslyEnteredJupyterServerBasedOnUrlTypedIntoQuickPick = undefined; + this.previouslyEnteredUrlTypedIntoQuickPick = undefined; let url = ""; @@ -171,6 +195,7 @@ export class JupyterServerIntegration } catch { // } + if (url) { this.previouslyEnteredUrlTypedIntoQuickPick = url; @@ -178,6 +203,7 @@ export class JupyterServerIntegration return [{ label, url } as JupyterServerCommand]; } + return [ { label: Localized.labelOfCommandToEnterUrl, @@ -185,6 +211,7 @@ export class JupyterServerIntegration }, ]; } + async removeJupyterServer?(server: JupyterServer): Promise { const tokenSource = new CancellationTokenSource(); @@ -208,6 +235,7 @@ export class JupyterServerIntegration traceDebug(`Failed to delete token ${server.id}`, ex), ); } + await this.storage.removeServer(server.id); } catch (ex) { traceDebug(`Failed to remove server ${server.id}`, ex); @@ -215,6 +243,7 @@ export class JupyterServerIntegration this._onDidChangeServers.fire(); } } + async provideJupyterServers( _token: CancellationToken, ): Promise { @@ -225,26 +254,33 @@ export class JupyterServerIntegration }; }); } + private cachedOfAuthInfo = new Map>(); + public async resolveJupyterServer( server: JupyterServer, token: CancellationToken, ): Promise { if (!this.cachedOfAuthInfo.get(server.id)) { const promise = this.resolveJupyterServerImpl(server, token); + promise.catch((ex) => { if (this.cachedOfAuthInfo.get(server.id) === promise) { traceError( `Failed to get auth information for server ${server.id}`, ex, ); + this.cachedOfAuthInfo.delete(server.id); } }); + this.cachedOfAuthInfo.set(server.id, promise); } + return this.cachedOfAuthInfo.get(server.id)!; } + private async resolveJupyterServerImpl( server: JupyterServer, cancelToken: CancellationToken, @@ -254,6 +290,7 @@ export class JupyterServerIntegration if (!serverInfo) { throw new Error("Server not found"); } + traceDebug( `Server Info for ${server.id} is ${JSON.stringify(serverInfo)}`, ); @@ -337,6 +374,7 @@ export class JupyterServerIntegration // https://github.com/microsoft/vscode-jupyter-hub/issues/53 const baseUrl = Uri.parse(rawBaseUrl); + traceDebug(`Resolved server ${server.id} to ${baseUrl.toString(true)}`); const brokenUrl = new this.nodeFetchImpl.Request(baseUrl.toString(true)) @@ -352,6 +390,7 @@ export class JupyterServerIntegration const ourFetch = async (input: Request, init?: RequestInit) => { const newUrl = input.url.replace(brokenUrl, correctUrl); + init = init || { method: input.method, body: input.body, @@ -406,6 +445,7 @@ export class JupyterServerIntegration ); } } + const connectionInformation: JupyterServerConnectionInformation = { baseUrl, token: result.token, diff --git a/Source/storage.ts b/Source/storage.ts index 519782b..5cc8385 100644 --- a/Source/storage.ts +++ b/Source/storage.ts @@ -15,6 +15,7 @@ function getAuthInfoKey(serverId: string) { } type Credentials = { username: string; + password: string; // Required to re-generate the token. token: string; // Generated using Hub REST API, this api token is used for connecting to Jupyter by Jupyter Extension. tokenId: string; // Requried when we want to delete the token using the Hub REST API. @@ -22,22 +23,27 @@ type Credentials = { export class JupyterHubServerStorage implements IJupyterHubServerStorage { private disposable = new DisposableStore(); + _onDidRemove = new EventEmitter(); + onDidRemove = this._onDidRemove.event; constructor( private readonly secrets: SecretStorage, private readonly globalMemento: Memento, ) {} + dispose() { this.disposable.dispose(); } + public get all(): JupyterHubServer[] { return this.globalMemento.get( serverListStorageKey, [], ); } + public async getCredentials( serverId: string, ): Promise< @@ -50,6 +56,7 @@ export class JupyterHubServerStorage implements IJupyterHubServerStorage { if (!js) { return; } + return JSON.parse(js || "") as Credentials; } catch (ex) { traceError( @@ -59,17 +66,24 @@ export class JupyterHubServerStorage implements IJupyterHubServerStorage { return; } } + public async addServerOrUpdate( server: { id: string; + baseUrl: string; + displayName: string; + serverName: string | undefined; }, auth: { username: string; + password: string; + token: string; + tokenId: string; }, ) { @@ -81,8 +95,10 @@ export class JupyterHubServerStorage implements IJupyterHubServerStorage { this.secrets.store(getAuthInfoKey(server.id), JSON.stringify(auth)), ]); } + public async removeServer(serverId: string) { const item = this.all.find((s) => s.id === serverId); + await Promise.all([ this.globalMemento.update( serverListStorageKey, diff --git a/Source/types.ts b/Source/types.ts index 0f00f62..72a02ae 100644 --- a/Source/types.ts +++ b/Source/types.ts @@ -25,7 +25,9 @@ export interface IJupyterRequestCreator { export type JupyterHubServer = { id: string; + baseUrl: string; + displayName: string; /** * Name of the server to start and use. @@ -36,16 +38,20 @@ export type JupyterHubServer = { export interface IJupyterHubServerStorage { onDidRemove: Event; + all: JupyterHubServer[]; + dispose(): void; getCredentials( serverId: string, ): Promise<{ username: string; password: string } | undefined>; + addServerOrUpdate( server: JupyterHubServer, auth: { username: string; password: string }, ): Promise; + removeServer(serverId: string): Promise; } @@ -54,18 +60,23 @@ export interface IJupyterHubConnectionValidator { baseUrl: string, authInfo: { username: string; + password: string; + token?: string; }, authenticator: IAuthenticator, token: CancellationToken, ): Promise; + ensureServerIsRunning( baseUrl: string, serverName: string | undefined, authInfo: { username: string; + password: string; + token?: string; }, authenticator: IAuthenticator, @@ -77,9 +88,12 @@ export interface IAuthenticator { getJupyterAuthInfo( options: { baseUrl: string; + authInfo: { username: string; + password: string; + token: string; }; }, diff --git a/Source/urlCapture.ts b/Source/urlCapture.ts index bb13390..3e0329f 100644 --- a/Source/urlCapture.ts +++ b/Source/urlCapture.ts @@ -43,8 +43,11 @@ import { export class JupyterHubUrlCapture { private readonly jupyterConnection: JupyterHubConnectionValidator; + private readonly displayNamesOfHandles = new Map(); + private readonly newAuthenticator: Authenticator; + private readonly disposable = new DisposableStore(); constructor( @@ -52,11 +55,14 @@ export class JupyterHubUrlCapture { private readonly storage: JupyterHubServerStorage, ) { this.newAuthenticator = new Authenticator(fetch); + this.jupyterConnection = new JupyterHubConnectionValidator(fetch); } + dispose() { this.disposable.dispose(); } + public async captureRemoteJupyterUrl( token: CancellationToken, initialUrl: string = "", @@ -80,9 +86,11 @@ export class JupyterHubUrlCapture { if (!(ex instanceof CancellationError)) { traceError("Failed to capture remote jupyter server", ex); } + throw ex; } } + private async captureRemoteJupyterUrlImpl( url: string = "", displayName: string = "", @@ -134,8 +142,11 @@ export class JupyterHubUrlCapture { this.fetch, token, ); + state.hubVersion = version; + state.urlWasPrePopulated = true; + nextStep = reasonForCapture === "captureNewUrl" ? "Get Username" @@ -146,9 +157,11 @@ export class JupyterHubUrlCapture { } else { // Uri has an error, show the error message by displaying the input box and pre-populating the url. validationErrorMessage = Localized.jupyterSelectURIInvalidURI; + nextStep = "Get Url"; } } + try { const stepsExecuted: Step[] = []; @@ -162,6 +175,7 @@ export class JupyterHubUrlCapture { throw new CancellationError(); } + nextStep = await step.run(state, token); if (nextStep === "Before") { @@ -169,8 +183,10 @@ export class JupyterHubUrlCapture { return; } + if (nextStep === "After") { sendJupyterHubUrlAdded(state.baseUrl, state.hubVersion, id); + await this.storage.addServerOrUpdate( { id, @@ -191,6 +207,7 @@ export class JupyterHubUrlCapture { label: state.displayName, }; } + if (nextStep) { // If nextStep is something that we have already executed in the past // then this means we're actually going back to that step. @@ -200,16 +217,20 @@ export class JupyterHubUrlCapture { continue; } + if (step.canNavigateBackToThis) { stepsExecuted.push(step.step); } + continue; } + if (stepsExecuted.length) { nextStep = stepsExecuted.pop(); continue; } + sendJupyterHubUrlNotAdded("cancel", step.step); return; @@ -219,8 +240,10 @@ export class JupyterHubUrlCapture { sendJupyterHubUrlNotAdded("cancel", ""); } else { traceError("Failed to capture remote jupyter server", ex); + sendJupyterHubUrlNotAdded("error", ""); } + throw ex; } finally { dispose(disposables); @@ -245,38 +268,54 @@ interface MultiStep { * Meaning, this step should be skipped in the future. */ disabled?: boolean; + canNavigateBackToThis: boolean; + dispose(): void; + run(state: State, token: CancellationToken): Promise; } type State = { displayNamesOfHandles: Map; + urlWasPrePopulated: boolean; + serverId: string; /** * Name of the server to start (named jupyter hub servers). */ serverName: string | undefined; + errorMessage: string; + url: string; + displayName: string; + baseUrl: string; + hubVersion: string; + auth: { username: string; + password: string; + token: string; + tokenId: string; }; }; class GetUrlStep extends DisposableStore implements MultiStep { step: Step = "Get Url"; + canNavigateBackToThis = true; constructor(private readonly fetch: SimpleFetch) { super(); } + async run(state: State, token: CancellationToken) { if (!state.url) { try { @@ -298,7 +337,9 @@ class GetUrlStep extends DisposableStore implements MultiStep { // We can ignore errors. } } + const validationMessage = state.errorMessage; + state.errorMessage = ""; const url = await this.add(new WorkflowInputCapture()).getValue( @@ -313,6 +354,7 @@ class GetUrlStep extends DisposableStore implements MultiStep { if (!isValidUrl(value)) { return Localized.jupyterSelectURIInvalidURI; } + try { await getJupyterHubBaseUrl(value, this.fetch, token); } catch (ex) { @@ -331,11 +373,16 @@ class GetUrlStep extends DisposableStore implements MultiStep { if (!url) { return; } + state.url = url; + state.baseUrl = await getJupyterHubBaseUrl(url, this.fetch, token); + state.hubVersion = await getVersion(state.baseUrl, this.fetch, token); + state.auth.username = state.auth.username || extractUserNameFromUrl(url) || ""; + state.auth.token = state.auth.token || extractTokenFromUrl(url) || ""; return "Get Username"; @@ -343,10 +390,12 @@ class GetUrlStep extends DisposableStore implements MultiStep { } class GetUserName extends DisposableStore implements MultiStep { step: Step = "Get Username"; + canNavigateBackToThis = true; async run(state: State, token: CancellationToken) { const errorMessage = state.errorMessage; + state.errorMessage = ""; // Never display this validation message again. const username = await this.add(new WorkflowInputCapture()).getValue( { @@ -363,6 +412,7 @@ class GetUserName extends DisposableStore implements MultiStep { if (!username) { return; } + state.auth.username = username; return "Get Password"; @@ -370,6 +420,7 @@ class GetUserName extends DisposableStore implements MultiStep { } class GetPassword extends DisposableStore implements MultiStep { step: Step = "Get Password"; + canNavigateBackToThis = true; constructor(private readonly authenticator: IAuthenticator) { @@ -413,6 +464,7 @@ class GetPassword extends DisposableStore implements MultiStep { if (!value) { return Localized.emptyPasswordErrorMessage; } + try { state.auth.password = value; @@ -424,8 +476,11 @@ class GetPassword extends DisposableStore implements MultiStep { }, token, ); + state.auth.token = result.token || ""; + state.auth.tokenId = result.tokenId || ""; + traceDebug( `Got an Auth token = ${state.auth.token.length} && ${ state.auth.token.trim().length @@ -461,6 +516,7 @@ class GetPassword extends DisposableStore implements MultiStep { if (!password) { return; } + state.auth.password = password; return "Verify Connection"; @@ -472,6 +528,7 @@ class VerifyConnection implements MultiStep { step: Step = "Verify Connection"; + canNavigateBackToThis = false; constructor( @@ -480,6 +537,7 @@ class VerifyConnection ) { super(); } + async run( state: State, token: CancellationToken, @@ -512,6 +570,7 @@ class VerifyConnection return "Get Username"; } } + return "Server Selector"; } } @@ -530,12 +589,15 @@ function getServerStatus(server: ApiTypes.ServerInfo) { } class ServerSelector extends DisposableStore implements MultiStep { step: Step = "Server Selector"; + disabled?: boolean | undefined; + canNavigateBackToThis = false; constructor(private readonly fetch: SimpleFetch) { super(); } + async run( state: State, token: CancellationToken, @@ -554,6 +616,7 @@ class ServerSelector extends DisposableStore implements MultiStep { (servers.length === 1 && !servers[0].name) ) { traceDebug("No servers found for the user"); + this.disabled = true; return "Get Display Name"; @@ -580,27 +643,33 @@ class ServerSelector extends DisposableStore implements MultiStep { if (!selection) { return; } + state.serverName = selection.server.name; } catch (err) { if (err instanceof CancellationError) { throw err; } + this.disabled = true; + traceWarn( "Failed to list all of the servers for the user, assuming there aren't any", err, ); } + return "Get Display Name"; } } class GetDisplayName extends DisposableStore implements MultiStep { step: Step = "Get Display Name"; + canNavigateBackToThis = false; constructor(private readonly storage: JupyterHubServerStorage) { super(); } + async run( state: State, token: CancellationToken, @@ -622,6 +691,7 @@ class GetDisplayName extends DisposableStore implements MultiStep { if (!displayName) { return; } + state.displayName = displayName; return "After"; @@ -634,18 +704,22 @@ export function getSuggestedDisplayName( usedNames: string[], ) { const usedNamesSet = new Set(usedNames.map((s) => s.toLowerCase())); + usedNamesSet.add("localhost"); + usedNamesSet.add(""); const isIPAddress = typeof parseInt(new URL(baseUrl).hostname.charAt(0), 10) === "number"; let hostName = isIPAddress ? "JupyterHub" : new URL(baseUrl).hostname; + hostName = serverName ? `${hostName} (${serverName})` : hostName; if (!isIPAddress && !usedNamesSet.has(hostName.toLowerCase())) { return hostName; } + for (let i = 0; i < 10; i++) { const name = i === 0 ? hostName : `${hostName} ${i}`; @@ -653,6 +727,7 @@ export function getSuggestedDisplayName( return name; } } + return "JupyterHub"; } diff --git a/Source/utils.ts b/Source/utils.ts index 2cbea14..2c94708 100644 --- a/Source/utils.ts +++ b/Source/utils.ts @@ -28,8 +28,10 @@ export async function getJupyterApi() { if (!ext) { throw new Error("Jupyter Extension not installed"); } + if (!ext.isActive) { await ext.activate(); } + return ext; } diff --git a/Source/validator.ts b/Source/validator.ts index c54ffb8..3fd2b61 100644 --- a/Source/validator.ts +++ b/Source/validator.ts @@ -40,11 +40,14 @@ export class JupyterHubConnectionValidator implements IJupyterHubConnectionValidator { constructor(private readonly fetch: SimpleFetch) {} + async validateJupyterUri( baseUrl: string, authInfo: { username: string; + password: string; + token: string; }, authenticator: IAuthenticator, @@ -55,6 +58,7 @@ export class JupyterHubConnectionValidator const masterCancel = disposable.add(new CancellationTokenSource()); const token = masterCancel.token; + disposable.add( mainCancel.onCancellationRequested(() => masterCancel.cancel()), ); @@ -103,17 +107,21 @@ export class JupyterHubConnectionValidator ); } } + throw err; } finally { disposable.dispose(); } } + async ensureServerIsRunning( baseUrl: string, serverName: string | undefined, authInfo: { username: string; + password: string; + token: string; }, authenticator: IAuthenticator, @@ -133,11 +141,13 @@ export class JupyterHubConnectionValidator ); const token = masterCancel.token; + disposable.add( mainCancel.onCancellationRequested(() => masterCancel.cancel(), ), ); + disposable.add( progressCancel.onCancellationRequested(() => masterCancel.cancel(), @@ -164,6 +174,7 @@ export class JupyterHubConnectionValidator if (!jupyterAuth) { throw new Error("Failed to get Jupyter Auth Info"); } + let retries = 0; while (true) { @@ -205,6 +216,7 @@ export class JupyterHubConnectionValidator } else { // Retry at least once before we give up. retries += 1; + traceDebug( `Waiting for Jupyter Server to start ${baseUrl}`, ); @@ -245,6 +257,7 @@ export class JupyterHubConnectionValidator ); } } + throw err; } finally { disposable.dispose(); @@ -261,11 +274,14 @@ export class JupyterHubConnectionValidator serverName: string | undefined, authInfo: { username: string; + password: string; + token: string; }, progress: Progress<{ message?: string | undefined; + increment?: number | undefined; }>, token: CancellationToken, @@ -285,6 +301,7 @@ export class JupyterHubConnectionValidator if (!serverName && (status.servers || {})[""]?.ready) { return; } + if (serverName && (status.servers || {})[serverName]?.ready) { return; } @@ -293,7 +310,9 @@ export class JupyterHubConnectionValidator return; } + progress.report({ message: Localized.startingJupyterServer }); + await startServer( baseUrl, authInfo.username, @@ -323,13 +342,16 @@ export class JupyterHubConnectionValidator if (!serverName && (status.servers || {})[""]?.ready) { return "didStartServer"; } + if (serverName && (status.servers || {})[serverName]?.ready) { return "didStartServer"; } + if (Date.now() - started > TIMEOUT_FOR_SESSION_MANAGER_READY) { if (!serverName && status.server) { // Old behaviour of returning the currently running server as the default server. const server = (status.servers || {})[""]; + traceDebug( `Default server status used from status.server 5 ${ status.server @@ -338,12 +360,14 @@ export class JupyterHubConnectionValidator return "didStartServer"; } + traceError( `Timeout waiting for Jupyter Server to start, current status = ${status.pending}`, ); return; } + await sleep(1000, token); } } catch (ex) { @@ -408,6 +432,7 @@ export async function getKernelSpecs( // At this point wait for the specs to change const promise = new Promise((resolve) => { specsManager.specsChanged.connect(resolve); + disposables.push( new Disposable(() => { try { @@ -431,6 +456,7 @@ export async function getKernelSpecs( if (hasKernelSpecs()) { return specsManager.specs; } + traceError( `SessionManager cannot enumerate kernelSpecs. Specs ${JSON.stringify(specsManager.specs?.kernelspecs)}.`, ); @@ -440,6 +466,7 @@ export async function getKernelSpecs( if (!(e instanceof CancellationError)) { traceError(`SessionManager:getKernelSpecs failure: `, e); } + return; } finally { dispose(disposables); @@ -453,9 +480,11 @@ export async function getKernelSpecs( try { sessionManager.dispose(); } catch {} + try { kernelManager.dispose(); } catch {} + try { specsManager.dispose(); } catch {} @@ -510,6 +539,7 @@ export async function handleSelfCertsError(message: string): Promise { if (value === enableOption) { solveCertificateProblem("self-signed", "allow"); + await workspace .getConfiguration("jupyter") .update( @@ -522,6 +552,7 @@ export async function handleSelfCertsError(message: string): Promise { } else { solveCertificateProblem("self-signed", "cancel"); } + return false; } @@ -554,6 +585,7 @@ export async function handleExpiredCertsError( if (value === enableOption) { solveCertificateProblem("expired", "allow"); + await workspace .getConfiguration("jupyter") .update( @@ -566,5 +598,6 @@ export async function handleExpiredCertsError( } else { solveCertificateProblem("expired", "cancel"); } + return false; }