Fix deployment wizard to not close when cancelling out of password prompt (#14083)

This commit is contained in:
Charles Gagnon
2021-01-28 09:00:46 -08:00
committed by GitHub
parent 14cf6add73
commit 8677ffc68c
6 changed files with 27 additions and 108 deletions

View File

@@ -1,83 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Deferred } from './promise';
const enum Status {
notStarted,
inProgress,
done
}
interface State<T> {
entry?: T,
error?: Error,
status: Status,
id: number,
pendingOperation: Deferred<void>
}
/**
* An implementation of Cache Manager which ensures that only one call to populate cache miss is pending at a given time.
* All remaining calls for retrieval are awaited until the one in progress finishes and then all awaited calls are resolved with the value
* from the cache.
*/
export class CacheManager<K, T> {
private _cache = new Map<K, State<T>>();
private _id = 0;
public async getCacheEntry(key: K, retrieveEntry: (key: K) => Promise<T>): Promise<T> {
const cacheHit: State<T> | undefined = this._cache.get(key);
// each branch either throws or returns the password.
if (cacheHit === undefined) {
// populate a new state entry and add it to the cache
const state: State<T> = {
status: Status.notStarted,
id: this._id++,
pendingOperation: new Deferred<void>()
};
this._cache.set(key, state);
// now that we have the state entry initialized, retry to fetch the cacheEntry
let returnValue: T = await this.getCacheEntry(key, retrieveEntry);
await state.pendingOperation;
return returnValue!;
} else {
switch (cacheHit.status) {
case Status.notStarted: {
cacheHit.status = Status.inProgress;
// retrieve and populate the missed cache hit.
try {
cacheHit.entry = await retrieveEntry(key);
} catch (error) {
cacheHit.error = error;
} finally {
cacheHit.status = Status.done;
// we do not reject here even in error case because we do not want our awaits on pendingOperation to throw
// We track our own error state and when all done we throw if an error had happened. This results
// in the rejection of the promised returned by this method.
cacheHit.pendingOperation.resolve();
}
return await this.getCacheEntry(key, retrieveEntry);
}
case Status.inProgress: {
await cacheHit.pendingOperation;
return await this.getCacheEntry(key, retrieveEntry);
}
case Status.done: {
if (cacheHit.error !== undefined) {
await cacheHit.pendingOperation;
throw cacheHit.error;
}
else {
await cacheHit.pendingOperation;
return cacheHit.entry!;
}
}
}
}
}
}

View File

@@ -218,7 +218,7 @@ export function couldNotFindAzureResource(name: string): string { return localiz
export function passwordResetFailed(error: any): string { return localize('arc.passwordResetFailed', "Failed to reset password. {0}", getErrorMessage(error)); } export function passwordResetFailed(error: any): string { return localize('arc.passwordResetFailed', "Failed to reset password. {0}", getErrorMessage(error)); }
export function errorConnectingToController(error: any): string { return localize('arc.errorConnectingToController', "Error connecting to controller. {0}", getErrorMessage(error, true)); } export function errorConnectingToController(error: any): string { return localize('arc.errorConnectingToController', "Error connecting to controller. {0}", getErrorMessage(error, true)); }
export function passwordAcquisitionFailed(error: any): string { return localize('arc.passwordAcquisitionFailed', "Failed to acquire password. {0}", getErrorMessage(error)); } export function passwordAcquisitionFailed(error: any): string { return localize('arc.passwordAcquisitionFailed', "Failed to acquire password. {0}", getErrorMessage(error)); }
export const loginFailed = localize('arc.loginFailed', "Error logging into controller, try again."); export const loginFailed = localize('arc.loginFailed', "Error logging into controller - wrong username or password");
export function errorVerifyingPassword(error: any): string { return localize('arc.errorVerifyingPassword', "Error encountered while verifying password. {0}", getErrorMessage(error)); } export function errorVerifyingPassword(error: any): string { return localize('arc.errorVerifyingPassword', "Error encountered while verifying password. {0}", getErrorMessage(error)); }
export const noControllersConnected = localize('noControllersConnected', "No Azure Arc controllers are currently connected. Please run the command: 'Connect to Existing Azure Arc Controller' and then try again"); export const noControllersConnected = localize('noControllersConnected', "No Azure Arc controllers are currently connected. Please run the command: 'Connect to Existing Azure Arc Controller' and then try again");
export const variableValueFetchForUnsupportedVariable = (variableName: string) => localize('getVariableValue.unknownVariableName', "Attempt to get variable value for unknown variable:{0}", variableName); export const variableValueFetchForUnsupportedVariable = (variableName: string) => localize('getVariableValue.unknownVariableName', "Attempt to get variable value for unknown variable:{0}", variableName);

View File

@@ -7,7 +7,6 @@ import * as arc from 'arc';
import * as azdata from 'azdata'; import * as azdata from 'azdata';
import * as rd from 'resource-deployment'; import * as rd from 'resource-deployment';
import { getControllerPassword, getRegisteredDataControllers, reacquireControllerPassword } from '../common/api'; import { getControllerPassword, getRegisteredDataControllers, reacquireControllerPassword } from '../common/api';
import { CacheManager } from '../common/cacheManager';
import { throwUnless } from '../common/utils'; import { throwUnless } from '../common/utils';
import * as loc from '../localizedConstants'; import * as loc from '../localizedConstants';
import { AzureArcTreeDataProvider } from '../ui/tree/azureArcTreeDataProvider'; import { AzureArcTreeDataProvider } from '../ui/tree/azureArcTreeDataProvider';
@@ -16,11 +15,10 @@ import { AzureArcTreeDataProvider } from '../ui/tree/azureArcTreeDataProvider';
* Class that provides options sources for an Arc Data Controller * Class that provides options sources for an Arc Data Controller
*/ */
export class ArcControllersOptionsSourceProvider implements rd.IOptionsSourceProvider { export class ArcControllersOptionsSourceProvider implements rd.IOptionsSourceProvider {
private _cacheManager = new CacheManager<string, string>();
readonly id = 'arc.controllers'; readonly id = 'arc.controllers';
constructor(private _treeProvider: AzureArcTreeDataProvider) { } constructor(private _treeProvider: AzureArcTreeDataProvider) { }
async getOptions(): Promise<string[] | azdata.CategoryValue[]> { public async getOptions(): Promise<string[] | azdata.CategoryValue[]> {
const controllers = await getRegisteredDataControllers(this._treeProvider); const controllers = await getRegisteredDataControllers(this._treeProvider);
throwUnless(controllers !== undefined && controllers.length !== 0, loc.noControllersConnected); throwUnless(controllers !== undefined && controllers.length !== 0, loc.noControllersConnected);
return controllers.map(ci => { return controllers.map(ci => {
@@ -28,8 +26,7 @@ export class ArcControllersOptionsSourceProvider implements rd.IOptionsSourcePro
}); });
} }
private async retrieveVariable(key: string): Promise<string> { public async getVariableValue(variableName: string, controllerLabel: string): Promise<string> {
const [variableName, controllerLabel] = JSON.parse(key);
const controller = (await getRegisteredDataControllers(this._treeProvider)).find(ci => ci.label === controllerLabel); const controller = (await getRegisteredDataControllers(this._treeProvider)).find(ci => ci.label === controllerLabel);
throwUnless(controller !== undefined, loc.noControllerInfoFound(controllerLabel)); throwUnless(controller !== undefined, loc.noControllerInfoFound(controllerLabel));
switch (variableName) { switch (variableName) {
@@ -42,12 +39,6 @@ export class ArcControllersOptionsSourceProvider implements rd.IOptionsSourcePro
} }
} }
getVariableValue(variableName: string, controllerLabel: string): Promise<string> {
// capture 'this' in an arrow function object
const retrieveVariable = (key: string) => this.retrieveVariable(key);
return this._cacheManager.getCacheEntry(JSON.stringify([variableName, controllerLabel]), retrieveVariable);
}
private async getPassword(controller: arc.DataController): Promise<string> { private async getPassword(controller: arc.DataController): Promise<string> {
let password = await getControllerPassword(this._treeProvider, controller.info); let password = await getControllerPassword(this._treeProvider, controller.info);
if (!password) { if (!password) {
@@ -57,7 +48,7 @@ export class ArcControllersOptionsSourceProvider implements rd.IOptionsSourcePro
return password; return password;
} }
getIsPassword(variableName: string): boolean { public getIsPassword(variableName: string): boolean {
switch (variableName) { switch (variableName) {
case 'endpoint': return false; case 'endpoint': return false;
case 'username': return false; case 'username': return false;

View File

@@ -3,6 +3,7 @@
* Licensed under the Source EULA. See License.txt in the project root for license information. * Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/ *--------------------------------------------------------------------------------------------*/
import * as path from 'path'; import * as path from 'path';
import { ErrorType, ErrorWithType } from 'resource-deployment';
import { ToolsInstallPath } from '../constants'; import { ToolsInstallPath } from '../constants';
import { ITool, NoteBookEnvironmentVariablePrefix } from '../interfaces'; import { ITool, NoteBookEnvironmentVariablePrefix } from '../interfaces';
@@ -12,6 +13,10 @@ export function getErrorMessage(error: any): string {
: typeof error === 'string' ? error : `${JSON.stringify(error, undefined, '\t')}`; : typeof error === 'string' ? error : `${JSON.stringify(error, undefined, '\t')}`;
} }
export function isUserCancelledError(err: any): boolean {
return err instanceof Error && 'type' in err && (<ErrorWithType>err).type === ErrorType.userCancelled;
}
export function getDateTimeString(): string { export function getDateTimeString(): string {
return new Date().toISOString().slice(0, 19).replace(/[^0-9]/g, ''); // Take the date time information and only leaving the numbers return new Date().toISOString().slice(0, 19).replace(/[^0-9]/g, ''); // Take the date time information and only leaving the numbers
} }

View File

@@ -10,7 +10,7 @@ import * as path from 'path';
import { IOptionsSourceProvider } from 'resource-deployment'; import { IOptionsSourceProvider } from 'resource-deployment';
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import * as nls from 'vscode-nls'; import * as nls from 'vscode-nls';
import { getDateTimeString, getErrorMessage, throwUnless } from '../common/utils'; import { getDateTimeString, getErrorMessage, isUserCancelledError, throwUnless } from '../common/utils';
import { AzureAccountFieldInfo, AzureLocationsFieldInfo, ComponentCSSStyles, DialogInfoBase, FieldInfo, FieldType, FilePickerFieldInfo, instanceOfDynamicEnablementInfo, IOptionsSource, KubeClusterContextFieldInfo, LabelPosition, NoteBookEnvironmentVariablePrefix, OptionsInfo, OptionsType, PageInfoBase, RowInfo, SectionInfo, TextCSSStyles } from '../interfaces'; import { AzureAccountFieldInfo, AzureLocationsFieldInfo, ComponentCSSStyles, DialogInfoBase, FieldInfo, FieldType, FilePickerFieldInfo, instanceOfDynamicEnablementInfo, IOptionsSource, KubeClusterContextFieldInfo, LabelPosition, NoteBookEnvironmentVariablePrefix, OptionsInfo, OptionsType, PageInfoBase, RowInfo, SectionInfo, TextCSSStyles } from '../interfaces';
import * as loc from '../localizedConstants'; import * as loc from '../localizedConstants';
import { apiService } from '../services/apiService'; import { apiService } from '../services/apiService';
@@ -667,12 +667,16 @@ async function configureOptionsSourceSubfields(context: FieldContext, optionsSou
try { try {
return await optionsSourceProvider.getVariableValue!(variableKey, value); return await optionsSourceProvider.getVariableValue!(variableKey, value);
} catch (e) { } catch (e) {
if (!isUserCancelledError(e)) {
// User cancelled is a normal scenario so we shouldn't disable anything in that case
// so that the user can retry if they want to
disableControlButtons(context.container); disableControlButtons(context.container);
context.container.message = { context.container.message = {
text: getErrorMessage(e), text: getErrorMessage(e),
description: '', description: '',
level: azdata.window.MessageLevel.Error level: azdata.window.MessageLevel.Error
}; };
}
throw e; throw e;
} }
}, },

View File

@@ -12,7 +12,7 @@ import { DeploymentType, NotebookWizardDeploymentProvider, NotebookWizardInfo }
import { IPlatformService } from '../../services/platformService'; import { IPlatformService } from '../../services/platformService';
import { NotebookWizardAutoSummaryPage } from './notebookWizardAutoSummaryPage'; import { NotebookWizardAutoSummaryPage } from './notebookWizardAutoSummaryPage';
import { NotebookWizardPage } from './notebookWizardPage'; import { NotebookWizardPage } from './notebookWizardPage';
import { ErrorType, ErrorWithType } from 'resource-deployment'; import { isUserCancelledError } from '../../common/utils';
export class NotebookWizardModel extends ResourceTypeModel { export class NotebookWizardModel extends ResourceTypeModel {
private _inputComponents: InputComponents = {}; private _inputComponents: InputComponents = {};
@@ -68,11 +68,13 @@ export class NotebookWizardModel extends ResourceTypeModel {
try { try {
notebook = await this.prepareNotebookAndEnvironment(); notebook = await this.prepareNotebookAndEnvironment();
} catch (e) { } catch (e) {
const isUserCancelled = e instanceof Error && 'type' in e && (<ErrorWithType>e).type === ErrorType.userCancelled; // If there was a user prompt while preparing the Notebook environment (such as prompting for password) and the user
// user cancellation is a normal scenario, we just bail out of the wizard without actually opening the notebook, so rethrow for any other case // cancelled out of that then we shouldn't display an error since that's a normal case but should still keep the Wizard
if (!isUserCancelled) { // open so they can make any changes they want and try again without needing to re-enter the information again.
throw e; if (isUserCancelledError(e)) {
return false;
} }
throw e;
} }
if (notebook) { // open the notebook if it was successfully prepared if (notebook) { // open the notebook if it was successfully prepared
await this.openNotebook(notebook); await this.openNotebook(notebook);