diff --git a/extensions/arc/notebooks/arcDeployment/deploy.postgres.existing.arc.ipynb b/extensions/arc/notebooks/arcDeployment/deploy.postgres.existing.arc.ipynb index aa9b329866..6a6a029e42 100644 --- a/extensions/arc/notebooks/arcDeployment/deploy.postgres.existing.arc.ipynb +++ b/extensions/arc/notebooks/arcDeployment/deploy.postgres.existing.arc.ipynb @@ -85,16 +85,36 @@ "cell_type": "code", "source": [ "# Required Values\n", + "env_var = \"AZDATA_NB_VAR_CONTROLLER_ENDPOINT\" in os.environ\n", + "if env_var:\n", + " controller_endpoint = os.environ[\"AZDATA_NB_VAR_CONTROLLER_ENDPOINT\"]\n", + "else:\n", + " sys.exit(f'environment variable: AZDATA_NB_VAR_CONTROLLER_ENDPOINT was not defined. Exiting\\n')\n", + "\n", + "env_var = \"AZDATA_NB_VAR_CONTROLLER_USERNAME\" in os.environ\n", + "if env_var:\n", + " controller_username = os.environ[\"AZDATA_NB_VAR_CONTROLLER_USERNAME\"]\n", + "else:\n", + " sys.exit(f'environment variable: AZDATA_NB_VAR_CONTROLLER_USERNAME was not defined. Exiting\\n')\n", + "\n", + "env_var = \"AZDATA_NB_VAR_CONTROLLER_PASSWORD\" in os.environ\n", + "if env_var:\n", + " controller_password = os.environ[\"AZDATA_NB_VAR_CONTROLLER_PASSWORD\"]\n", + "else:\n", + " sys.exit(f'environment variable: AZDATA_NB_VAR_CONTROLLER_PASSWORD was not defined. Exiting\\n')\n", + "\n", "env_var = \"AZDATA_NB_VAR_POSTGRES_SERVER_GROUP_NAME\" in os.environ\n", "if env_var:\n", " server_group_name = os.environ[\"AZDATA_NB_VAR_POSTGRES_SERVER_GROUP_NAME\"]\n", "else:\n", " sys.exit(f'environment variable: AZDATA_NB_VAR_POSTGRES_SERVER_GROUP_NAME was not defined. Exiting\\n')\n", + "\n", "env_var = \"AZDATA_NB_VAR_POSTGRES_SERVER_GROUP_PASSWORD\" in os.environ\n", "if env_var:\n", " postgres_password = os.environ[\"AZDATA_NB_VAR_POSTGRES_SERVER_GROUP_PASSWORD\"]\n", "else:\n", " sys.exit(f'environment variable: AZDATA_NB_VAR_POSTGRES_SERVER_GROUP_PASSWORD was not defined. Exiting\\n') \n", + "\n", "env_var = \"AZDATA_NB_VAR_POSTGRES_STORAGE_CLASS_DATA\" in os.environ\n", "if env_var:\n", " postgres_storage_class_data = os.environ[\"AZDATA_NB_VAR_POSTGRES_STORAGE_CLASS_DATA\"]\n", @@ -159,6 +179,21 @@ "azdata_cell_guid": "90b0e162-2987-463f-9ce6-12dda1267189" } }, + { + "cell_type": "code", + "source": [ + "# Login to the data controller.\n", + "#\n", + "os.environ[\"AZDATA_PASSWORD\"] = controller_password\n", + "cmd = f'azdata login -e {controller_endpoint} -u {controller_username}'\n", + "out=run_command()" + ], + "metadata": { + "azdata_cell_guid": "71366399-5963-4e24-b2f2-6bb5bffba4ec" + }, + "outputs": [], + "execution_count": null + }, { "cell_type": "code", "source": [ @@ -187,4 +222,4 @@ "execution_count": null } ] -} +} \ No newline at end of file diff --git a/extensions/arc/notebooks/arcDeployment/deploy.sql.existing.arc.ipynb b/extensions/arc/notebooks/arcDeployment/deploy.sql.existing.arc.ipynb index a38509df4e..b19b8af493 100644 --- a/extensions/arc/notebooks/arcDeployment/deploy.sql.existing.arc.ipynb +++ b/extensions/arc/notebooks/arcDeployment/deploy.sql.existing.arc.ipynb @@ -85,21 +85,42 @@ "cell_type": "code", "source": [ "# Required Values\n", + "env_var = \"AZDATA_NB_VAR_CONTROLLER_ENDPOINT\" in os.environ\n", + "if env_var:\n", + " controller_endpoint = os.environ[\"AZDATA_NB_VAR_CONTROLLER_ENDPOINT\"]\n", + "else:\n", + " sys.exit(f'environment variable: AZDATA_NB_VAR_CONTROLLER_ENDPOINT was not defined. Exiting\\n')\n", + "\n", + "env_var = \"AZDATA_NB_VAR_CONTROLLER_USERNAME\" in os.environ\n", + "if env_var:\n", + " controller_username = os.environ[\"AZDATA_NB_VAR_CONTROLLER_USERNAME\"]\n", + "else:\n", + " sys.exit(f'environment variable: AZDATA_NB_VAR_CONTROLLER_USERNAME was not defined. Exiting\\n')\n", + "\n", + "env_var = \"AZDATA_NB_VAR_CONTROLLER_PASSWORD\" in os.environ\n", + "if env_var:\n", + " controller_password = os.environ[\"AZDATA_NB_VAR_CONTROLLER_PASSWORD\"]\n", + "else:\n", + " sys.exit(f'environment variable: AZDATA_NB_VAR_CONTROLLER_PASSWORD was not defined. Exiting\\n')\n", + "\n", "env_var = \"AZDATA_NB_VAR_SQL_INSTANCE_NAME\" in os.environ\n", "if env_var:\n", " mssql_instance_name = os.environ[\"AZDATA_NB_VAR_SQL_INSTANCE_NAME\"]\n", "else:\n", " sys.exit(f'environment variable: AZDATA_NB_VAR_SQL_INSTANCE_NAME was not defined. Exiting\\n')\n", + "\n", "env_var = \"AZDATA_NB_VAR_SQL_PASSWORD\" in os.environ\n", "if env_var:\n", " mssql_password = os.environ[\"AZDATA_NB_VAR_SQL_PASSWORD\"]\n", "else:\n", - " sys.exit(f'environment variable: AZDATA_NB_VAR_SQL_PASSWORD was not defined. Exiting\\n') \n", + " sys.exit(f'environment variable: AZDATA_NB_VAR_SQL_PASSWORD was not defined. Exiting\\n')\n", + "\n", "env_var = \"AZDATA_NB_VAR_SQL_STORAGE_CLASS_DATA\" in os.environ\n", "if env_var:\n", " mssql_storage_class_data = os.environ[\"AZDATA_NB_VAR_SQL_STORAGE_CLASS_DATA\"]\n", "else:\n", - " sys.exit(f'environment variable: AZDATA_NB_VAR_SQL_STORAGE_CLASS_DATA was not defined. Exiting\\n') \n", + " sys.exit(f'environment variable: AZDATA_NB_VAR_SQL_STORAGE_CLASS_DATA was not defined. Exiting\\n')\n", + "\n", "env_var = \"AZDATA_NB_VAR_SQL_STORAGE_CLASS_LOGS\" in os.environ\n", "if env_var:\n", " mssql_storage_class_logs = os.environ[\"AZDATA_NB_VAR_SQL_STORAGE_CLASS_LOGS\"]\n", @@ -123,6 +144,21 @@ "azdata_cell_guid": "90b0e162-2987-463f-9ce6-12dda1267189" } }, + { + "cell_type": "code", + "source": [ + "# Login to the data controller.\n", + "#\n", + "os.environ[\"AZDATA_PASSWORD\"] = controller_password\n", + "cmd = f'azdata login -e {controller_endpoint} -u {controller_username}'\n", + "out=run_command()" + ], + "metadata": { + "azdata_cell_guid": "1437c536-17e8-4a7f-80c1-aa43ad02686c" + }, + "outputs": [], + "execution_count": null + }, { "cell_type": "code", "source": [ @@ -139,4 +175,4 @@ "execution_count": null } ] -} +} \ No newline at end of file diff --git a/extensions/arc/package.json b/extensions/arc/package.json index 13ba8a17c2..602426df8f 100644 --- a/extensions/arc/package.json +++ b/extensions/arc/package.json @@ -599,6 +599,25 @@ { "title": "%arc.sql.settings.section.title%", "fields": [ + { + "label": "%arc.controller%", + "variableName": "AZDATA_NB_VAR_ARC_CONTROLLER", + "type": "options", + "editable": false, + "required": true, + "options": { + "source": { + "type": "ArcControllersOptionsSource", + "variableNames": { + "endpoint": "AZDATA_NB_VAR_CONTROLLER_ENDPOINT", + "username": "AZDATA_NB_VAR_CONTROLLER_USERNAME", + "password": "AZDATA_NB_VAR_CONTROLLER_PASSWORD" + } + }, + "optionsType": "dropdown" + }, + "labelWidth": "100%" + }, { "label": "%arc.sql.instance.name%", "variableName": "AZDATA_NB_VAR_SQL_INSTANCE_NAME", @@ -711,6 +730,25 @@ { "title": "%arc.postgres.settings.section.title%", "fields": [ + { + "label": "%arc.controller%", + "variableName": "AZDATA_NB_VAR_ARC_CONTROLLER", + "type": "options", + "editable": false, + "required": true, + "options": { + "source": { + "type": "ArcControllersOptionsSource", + "variableNames": { + "endpoint": "AZDATA_NB_VAR_CONTROLLER_ENDPOINT", + "username": "AZDATA_NB_VAR_CONTROLLER_USERNAME", + "password": "AZDATA_NB_VAR_CONTROLLER_PASSWORD" + } + }, + "optionsType": "dropdown" + }, + "labelWidth": "100%" + }, { "label": "%arc.postgres.server.group.name%", "variableName": "AZDATA_NB_VAR_POSTGRES_SERVER_GROUP_NAME", diff --git a/extensions/arc/package.nls.json b/extensions/arc/package.nls.json index 1610e765d4..9534873acd 100644 --- a/extensions/arc/package.nls.json +++ b/extensions/arc/package.nls.json @@ -76,6 +76,9 @@ "resource.type.picker.display.name": "Resource Type", "sql.managed.instance.display.name": "Azure SQL managed instance - Azure Arc", "postgres.server.group.display.name": "PostgreSQL server groups - Azure Arc", + + "arc.controller": "Target Azure Arc Controller", + "arc.sql.new.dialog.title": "Deploy Azure SQL managed instance - Azure Arc (preview)", "arc.sql.settings.section.title": "SQL Connection information", "arc.azure.section.title": "Azure information", diff --git a/extensions/arc/src/extension.ts b/extensions/arc/src/extension.ts index 2dc0b6d382..47668178e9 100644 --- a/extensions/arc/src/extension.ts +++ b/extensions/arc/src/extension.ts @@ -5,9 +5,10 @@ import * as arc from 'arc'; import * as vscode from 'vscode'; +import { UserCancelledError } from './common/utils'; import { IconPathHelper, refreshActionId } from './constants'; import * as loc from './localizedConstants'; -import { ConnectToControllerDialog } from './ui/dialogs/connectControllerDialog'; +import { ConnectToControllerDialog, PasswordToControllerDialog } from './ui/dialogs/connectControllerDialog'; import { AzureArcTreeDataProvider } from './ui/tree/azureArcTreeDataProvider'; import { ControllerTreeNode } from './ui/tree/controllerTreeNode'; import { TreeNode } from './ui/tree/treeNode'; @@ -57,11 +58,24 @@ export async function activate(context: vscode.ExtensionContext): Promise { - return (await treeDataProvider.getChildren()) - .filter(node => node instanceof ControllerTreeNode) - .map(node => (node as ControllerTreeNode).model.info); - + getRegisteredDataControllers: async () => (await treeDataProvider.getChildren()) + .filter(node => node instanceof ControllerTreeNode) + .map(node => ({ + label: (node as ControllerTreeNode).model.label, + info: (node as ControllerTreeNode).model.info + })), + getControllerPassword: async (controllerInfo: arc.ControllerInfo) => { + return await treeDataProvider.getPassword(controllerInfo); + }, + reacquireControllerPassword: async (controllerInfo: arc.ControllerInfo) => { + let model; + const dialog = new PasswordToControllerDialog(treeDataProvider); + dialog.showDialog(controllerInfo); + model = await dialog.waitForClose(); + if (!model) { + throw new UserCancelledError(); + } + return model.password; } }; } diff --git a/extensions/arc/src/localizedConstants.ts b/extensions/arc/src/localizedConstants.ts index 0a4e0d23c1..8494044e54 100644 --- a/extensions/arc/src/localizedConstants.ts +++ b/extensions/arc/src/localizedConstants.ts @@ -73,6 +73,7 @@ export const indirect = localize('arc.indirect', "Indirect"); export const loading = localize('arc.loading', "Loading..."); export const refreshToEnterCredentials = localize('arc.refreshToEnterCredentials', "Refresh node to enter credentials"); export const connectToController = localize('arc.connectToController', "Connect to Existing Controller"); +export const passwordToController = localize('arc.passwordToController', "Provide Password to Controller"); export const controllerUrl = localize('arc.controllerUrl', "Controller URL"); export const controllerName = localize('arc.controllerName', "Name"); export const defaultControllerName = localize('arc.defaultControllerName', "arc-dc"); @@ -81,6 +82,7 @@ export const password = localize('arc.password', "Password"); export const rememberPassword = localize('arc.rememberPassword', "Remember Password"); export const connect = localize('arc.connect', "Connect"); export const cancel = localize('arc.cancel', "Cancel"); +export const ok = localize('arc.ok', "Ok"); export const notConfigured = localize('arc.notConfigured', "Not Configured"); // Database States - see https://docs.microsoft.com/sql/relational-databases/databases/database-states @@ -156,3 +158,6 @@ export function invalidResourceDeletionName(name: string): string { return local export function couldNotFindAzureResource(name: string): string { return localize('arc.couldNotFindAzureResource', "Could not find Azure resource for {0}", name); } 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)); } +export function passwordAcquisitionFailed(error: any): string { return localize('arc.passwordAcquisitionFailed', "Failed to acquire password. {0}", getErrorMessage(error)); } +export const invalidPassword = localize('arc.invalidPassword', "The password did not work, try again."); +export function errorVerifyingPassword(error: any): string { return localize('arc.errorVerifyingPassword', "Error encountered while verifying password. {0}", getErrorMessage(error)); } diff --git a/extensions/arc/src/typings/arc.d.ts b/extensions/arc/src/typings/arc.d.ts index 80933cf272..8784c2ac54 100644 --- a/extensions/arc/src/typings/arc.d.ts +++ b/extensions/arc/src/typings/arc.d.ts @@ -35,7 +35,13 @@ declare module 'arc' { resources: ResourceInfo[] }; + export interface DataController { + label: string, + info: ControllerInfo + } export interface IExtension { - getRegisteredDataControllers(): Promise; + getRegisteredDataControllers(): Promise; + getControllerPassword(controllerInfo: ControllerInfo): Promise; + reacquireControllerPassword(controllerInfo: ControllerInfo, password: string, retryCount?: number): Promise; } } diff --git a/extensions/arc/src/ui/dialogs/connectControllerDialog.ts b/extensions/arc/src/ui/dialogs/connectControllerDialog.ts index c63137fa8d..8fad5ee42f 100644 --- a/extensions/arc/src/ui/dialogs/connectControllerDialog.ts +++ b/extensions/arc/src/ui/dialogs/connectControllerDialog.ts @@ -5,6 +5,7 @@ import { ControllerInfo } from 'arc'; import * as azdata from 'azdata'; +import * as azdataExt from 'azdata-ext'; import { v4 as uuid } from 'uuid'; import * as vscode from 'vscode'; import { Deferred } from '../../common/promise'; @@ -12,94 +13,136 @@ import * as loc from '../../localizedConstants'; import { ControllerModel } from '../../models/controllerModel'; import { InitializingComponent } from '../components/initializingComponent'; import { AzureArcTreeDataProvider } from '../tree/azureArcTreeDataProvider'; +import { getErrorMessage } from '../../common/utils'; export type ConnectToControllerDialogModel = { controllerModel: ControllerModel, password: string }; -export class ConnectToControllerDialog extends InitializingComponent { - private modelBuilder!: azdata.ModelBuilder; +abstract class ControllerDialogBase extends InitializingComponent { + protected modelBuilder!: azdata.ModelBuilder; + protected dialog: azdata.window.Dialog; - private urlInputBox!: azdata.InputBoxComponent; - private nameInputBox!: azdata.InputBoxComponent; - private usernameInputBox!: azdata.InputBoxComponent; - private passwordInputBox!: azdata.InputBoxComponent; - private rememberPwCheckBox!: azdata.CheckBoxComponent; + protected urlInputBox!: azdata.InputBoxComponent; + protected nameInputBox!: azdata.InputBoxComponent; + protected usernameInputBox!: azdata.InputBoxComponent; + protected passwordInputBox!: azdata.InputBoxComponent; - private _completionPromise = new Deferred(); - - private _id!: string; - - constructor(private _treeDataProvider: AzureArcTreeDataProvider) { - super(); + protected getComponents(): (azdata.FormComponent & { layout?: azdata.FormItemLayout | undefined; })[] { + return [ + { + component: this.urlInputBox, + title: loc.controllerUrl, + required: true + }, { + component: this.nameInputBox, + title: loc.controllerName, + required: false + }, { + component: this.usernameInputBox, + title: loc.username, + required: true + }, { + component: this.passwordInputBox, + title: loc.password, + required: true + } + ]; } - public showDialog(controllerInfo?: ControllerInfo, password?: string): azdata.window.Dialog { - this._id = controllerInfo?.id ?? uuid(); - const dialog = azdata.window.createModelViewDialog(loc.connectToController); - dialog.cancelButton.onClick(() => this.handleCancel()); - dialog.registerContent(async view => { - this.modelBuilder = view.modelBuilder; + protected abstract fieldToFocusOn(): azdata.Component; + protected readonlyFields(): azdata.InputBoxComponent[] { return []; } - this.urlInputBox = this.modelBuilder.inputBox() - .withProperties({ - value: controllerInfo?.url, - // If we have a model then we're editing an existing connection so don't let them modify the URL - readOnly: !!controllerInfo - }).component(); - this.nameInputBox = this.modelBuilder.inputBox() - .withProperties({ - value: controllerInfo?.name - }).component(); - this.usernameInputBox = this.modelBuilder.inputBox() - .withProperties({ - value: controllerInfo?.username - }).component(); - this.passwordInputBox = this.modelBuilder.inputBox() - .withProperties({ - inputType: 'password', - value: password - }) - .component(); - this.rememberPwCheckBox = this.modelBuilder.checkBox() - .withProperties({ - label: loc.rememberPassword, - checked: controllerInfo?.rememberPassword - }).component(); + protected initializeFields(controllerInfo: ControllerInfo | undefined, password: string | undefined) { + this.urlInputBox = this.modelBuilder.inputBox() + .withProperties({ + value: controllerInfo?.url, + // If we have a model then we're editing an existing connection so don't let them modify the URL + readOnly: !!controllerInfo + }).component(); + this.nameInputBox = this.modelBuilder.inputBox() + .withProperties({ + value: controllerInfo?.name + }).component(); + this.usernameInputBox = this.modelBuilder.inputBox() + .withProperties({ + value: controllerInfo?.username + }).component(); + this.passwordInputBox = this.modelBuilder.inputBox() + .withProperties({ + inputType: 'password', + value: password + }).component(); + } + + protected completionPromise = new Deferred(); + protected id!: string; + + constructor(protected treeDataProvider: AzureArcTreeDataProvider, title: string) { + super(); + this.dialog = azdata.window.createModelViewDialog(title); + } + + public showDialog(controllerInfo?: ControllerInfo, password: string | undefined = undefined): azdata.window.Dialog { + this.id = controllerInfo?.id ?? uuid(); + this.dialog.cancelButton.onClick(() => this.handleCancel()); + this.dialog.registerContent(async (view) => { + this.modelBuilder = view.modelBuilder; + this.initializeFields(controllerInfo, password); let formModel = this.modelBuilder.formContainer() .withFormItems([{ - components: [ - { - component: this.urlInputBox, - title: loc.controllerUrl, - required: true - }, { - component: this.nameInputBox, - title: loc.controllerName, - required: false - }, { - component: this.usernameInputBox, - title: loc.username, - required: true - }, { - component: this.passwordInputBox, - title: loc.password, - required: true - }, { - component: this.rememberPwCheckBox, - title: '' - } - ], + components: this.getComponents(), title: '' }]).withLayout({ width: '100%' }).component(); await view.initializeModel(formModel); - this.urlInputBox.focus(); + await this.fieldToFocusOn().focus(); + this.readonlyFields().forEach(f => f.readOnly = true); this.initialized = true; }); - dialog.registerCloseValidator(async () => await this.validate()); - dialog.okButton.label = loc.connect; - dialog.cancelButton.label = loc.cancel; - azdata.window.openDialog(dialog); - return dialog; + this.dialog.registerCloseValidator(async () => await this.validate()); + this.dialog.okButton.label = loc.connect; + this.dialog.cancelButton.label = loc.cancel; + azdata.window.openDialog(this.dialog); + return this.dialog; + } + + public abstract async validate(): Promise; + + private handleCancel(): void { + this.completionPromise.resolve(undefined); + } + + public waitForClose(): Promise { + return this.completionPromise.promise; + } +} + +export class ConnectToControllerDialog extends ControllerDialogBase { + protected rememberPwCheckBox!: azdata.CheckBoxComponent; + + protected fieldToFocusOn() { + return this.urlInputBox; + } + + protected getComponents() { + return [ + ...super.getComponents(), + { + component: this.rememberPwCheckBox, + title: '' + }]; + } + + protected initializeFields(controllerInfo: ControllerInfo | undefined, password: string | undefined) { + super.initializeFields(controllerInfo, password); + this.rememberPwCheckBox = this.modelBuilder.checkBox() + .withProperties({ + label: loc.rememberPassword, + checked: controllerInfo?.rememberPassword + }).component(); + } + + constructor(treeDataProvider: AzureArcTreeDataProvider) { + super(treeDataProvider, loc.connectToController); } public async validate(): Promise { @@ -120,32 +163,86 @@ export class ConnectToControllerDialog extends InitializingComponent { url = `${url}:30080`; } const controllerInfo: ControllerInfo = { - id: this._id, + id: this.id, url: url, name: this.nameInputBox.value ?? '', username: this.usernameInputBox.value, rememberPassword: this.rememberPwCheckBox.checked ?? false, resources: [] }; - const controllerModel = new ControllerModel(this._treeDataProvider, controllerInfo, this.passwordInputBox.value); + const controllerModel = new ControllerModel(this.treeDataProvider, controllerInfo, this.passwordInputBox.value); try { // Validate that we can connect to the controller, this also populates the controllerRegistration from the connection response. await controllerModel.refresh(false); // default info.name to the name of the controller instance if the user did not specify their own and to a pre-canned default if for some weird reason controller endpoint returned instanceName is also not a valid value controllerModel.info.name = controllerModel.info.name || controllerModel.controllerConfig?.metadata.name || loc.defaultControllerName; } catch (err) { - vscode.window.showErrorMessage(loc.connectToControllerFailed(this.urlInputBox.value, err)); + this.dialog.message = { + text: loc.connectToControllerFailed(this.urlInputBox.value, err), + level: azdata.window.MessageLevel.Error + }; return false; } - this._completionPromise.resolve({ controllerModel: controllerModel, password: this.passwordInputBox.value }); + this.completionPromise.resolve({ controllerModel: controllerModel, password: this.passwordInputBox.value }); + return true; + } +} +export class PasswordToControllerDialog extends ControllerDialogBase { + + constructor(treeDataProvider: AzureArcTreeDataProvider) { + super(treeDataProvider, loc.passwordToController); + } + + protected fieldToFocusOn() { + return this.passwordInputBox; + } + + protected readonlyFields() { + return [ + this.urlInputBox, + this.nameInputBox, + this.usernameInputBox + ]; + } + + public async validate(): Promise { + if (!this.passwordInputBox.value) { + return false; + } + const azdataApi = vscode.extensions.getExtension(azdataExt.extension.name)?.exports; + try { + await azdataApi.azdata.login(this.urlInputBox.value!, this.usernameInputBox.value!, this.passwordInputBox.value); + } catch (e) { + if (getErrorMessage(e).match(/Wrong username or password/i)) { + this.dialog.message = { + text: loc.invalidPassword, + level: azdata.window.MessageLevel.Error + }; + return false; + } else { + this.dialog.message = { + text: loc.errorVerifyingPassword(e), + level: azdata.window.MessageLevel.Error + }; + return false; + } + } + const controllerInfo: ControllerInfo = { + id: this.id, + url: this.urlInputBox.value!, + name: this.nameInputBox.value!, + username: this.usernameInputBox.value!, + rememberPassword: false, + resources: [] + }; + const controllerModel = new ControllerModel(this.treeDataProvider, controllerInfo, this.passwordInputBox.value); + this.completionPromise.resolve({ controllerModel: controllerModel, password: this.passwordInputBox.value }); return true; } - private handleCancel(): void { - this._completionPromise.resolve(undefined); - } - - public waitForClose(): Promise { - return this._completionPromise.promise; + public showDialog(controllerInfo?: ControllerInfo): azdata.window.Dialog { + const dialog = super.showDialog(controllerInfo); + dialog.okButton.label = loc.ok; + return dialog; } } diff --git a/extensions/azdata/src/azdata.ts b/extensions/azdata/src/azdata.ts index 3e3e5bd3d6..520447803d 100644 --- a/extensions/azdata/src/azdata.ts +++ b/extensions/azdata/src/azdata.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as azdataExt from 'azdata-ext'; +import * as fs from 'fs'; import * as os from 'os'; import { SemVer } from 'semver'; import * as vscode from 'vscode'; @@ -13,7 +14,6 @@ import Logger from './common/logger'; import { getErrorMessage, searchForCmd } from './common/utils'; import { azdataAcceptEulaKey, azdataConfigSection, azdataFound, azdataHostname, azdataInstallKey, azdataReleaseJson, azdataUpdateKey, azdataUri, debugConfigKey, eulaAccepted, eulaUrl, microsoftPrivacyStatementUrl } from './constants'; import * as loc from './localizedConstants'; -import * as fs from 'fs'; const enum AzdataDeployOption { dontPrompt = 'dontPrompt', @@ -136,7 +136,7 @@ export class AzdataTool implements IAzdataTool { // to get the correct stderr out. The actual value we get is something like // ERROR: { stderr: '...' } // so we also need to trim off the start that isn't a valid JSON blob - err.stderr = JSON.parse(err.stderr.substring(err.stderr.indexOf('{'))).stderr; + err.stderr = JSON.parse(err.stderr.substring(err.stderr.indexOf('{'), err.stderr.indexOf('}') + 1)).stderr; } catch (err) { // it means this was probably some other generic error (such as command not being found) // check if azdata still exists if it does then rethrow the original error if not then emit a new specific error. diff --git a/extensions/azdata/src/localizedConstants.ts b/extensions/azdata/src/localizedConstants.ts index 493f6149ce..9f052d14df 100644 --- a/extensions/azdata/src/localizedConstants.ts +++ b/extensions/azdata/src/localizedConstants.ts @@ -23,7 +23,7 @@ export const decline = localize('azdata.decline', "Decline"); export const doNotAskAgain = localize('azdata.doNotAskAgain', "Don't Ask Again"); export const askLater = localize('azdata.askLater', "Ask Later"); export const downloadingTo = (name: string, location: string): string => localize('azdata.downloadingTo', "Downloading {0} to {1}", name, location); -export const executingCommand = (command: string, args: string[]): string => localize('azdata.executingCommand', "Executing command \"{0} {1}\"", command, args?.join(' ')); +export const executingCommand = (command: string, args: string[]): string => localize('azdata.executingCommand', "Executing command: '{0} {1}'", command, args?.join(' ')); export const stdoutOutput = (stdout: string): string => localize('azdata.stdoutOutput', "stdout: {0}", stdout); export const stderrOutput = (stderr: string): string => localize('azdata.stderrOutput', "stderr: {0}", stderr); export const checkingLatestAzdataVersion = localize('azdata.checkingLatestAzdataVersion', "Checking for latest available version of azdata"); diff --git a/extensions/resource-deployment/src/helpers/cacheManager.ts b/extensions/resource-deployment/src/helpers/cacheManager.ts new file mode 100644 index 0000000000..bd9624065f --- /dev/null +++ b/extensions/resource-deployment/src/helpers/cacheManager.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { + entry?: T, + error?: Error, + status: Status, + id: number, + pendingOperation: Deferred +} + +/** + * 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 { + private _cache = new Map>(); + private _id = 0; + + public async getCacheEntry(key: K, retrieveEntry: (key: K) => Promise): Promise { + const cacheHit: State | 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 = { + status: Status.notStarted, + id: this._id++, + pendingOperation: new Deferred() + }; + 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!; + } + } + } + } + } +} diff --git a/extensions/resource-deployment/src/helpers/optionSources.ts b/extensions/resource-deployment/src/helpers/optionSources.ts new file mode 100644 index 0000000000..379316295a --- /dev/null +++ b/extensions/resource-deployment/src/helpers/optionSources.ts @@ -0,0 +1,100 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as arc from 'arc'; +import { CategoryValue } from 'azdata'; +import { IOptionsSource } from '../interfaces'; +import * as loc from '../localizedConstants'; +import { apiService } from '../services/apiService'; +import { throwUnless } from '../utils'; +import { CacheManager } from './cacheManager'; + + +export type OptionsSourceType = 'ArcControllersOptionsSource'; + +const OptionsSources = new Map OptionsSource>(); +export abstract class OptionsSource implements IOptionsSource { + + private _variableNames!: { [index: string]: string; }; + private _type!: OptionsSourceType; + + get type(): OptionsSourceType { return this._type; } + get variableNames(): { [index: string]: string; } { return this._variableNames; } + + abstract async getOptions(): Promise; + abstract async getVariableValue(variableName: string, input: string): Promise; + abstract getIsPassword(variableName: string): boolean; + + protected constructor() { + } + + static construct(optionsSourceType: OptionsSourceType, variableNames: { [index: string]: string }): OptionsSource { + const sourceConstructor = OptionsSources.get(optionsSourceType); + throwUnless(sourceConstructor !== undefined, loc.noOptionsSourceDefined(optionsSourceType)); + const obj = new sourceConstructor(); + obj._type = optionsSourceType; + obj._variableNames = variableNames; + return obj; + } +} + +export class ArcControllersOptionsSource extends OptionsSource { + private _cacheManager = new CacheManager(); + constructor() { + super(); + } + + async getOptions(): Promise { + const controllers = await apiService.arcApi.getRegisteredDataControllers(); + throwUnless(controllers !== undefined && controllers.length !== 0, loc.noControllersConnected); + return controllers.map(ci => { + return ci.label; + }); + } + + async getVariableValue(variableName: string, controllerLabel: string): Promise { + const retrieveVariable = async (key: string) => { + const [variableName, controllerLabel] = JSON.parse(key); + const controllers = await apiService.arcApi.getRegisteredDataControllers(); + const controller = controllers!.find(ci => ci.label === controllerLabel); + throwUnless(controller !== undefined, loc.noControllerInfoFound(controllerLabel)); + switch (variableName) { + case 'endpoint': + return controller.info.url; + case 'username': + return controller.info.username; + case 'password': + const fetchedPassword = await this.getPassword(controller); + return fetchedPassword; + default: + throw new Error(loc.variableValueFetchForUnsupportedVariable(variableName)); + } + }; + const variableValue = await this._cacheManager.getCacheEntry(JSON.stringify([variableName, controllerLabel]), retrieveVariable); + return variableValue; + } + + private async getPassword(controller: arc.DataController): Promise { + let password = await apiService.arcApi.getControllerPassword(controller.info); + if (!password) { + password = await apiService.arcApi.reacquireControllerPassword(controller.info, password); + } + throwUnless(password !== undefined, loc.noPasswordFound(controller.label)); + return password; + } + + getIsPassword(variableName: string): boolean { + switch (variableName) { + case 'endpoint': + case 'username': + return false; + case 'password': + return true; + default: + throw new Error(loc.isPasswordFetchForUnsupportedVariable(variableName)); + } + } +} +OptionsSources.set(ArcControllersOptionsSource.name, ArcControllersOptionsSource); diff --git a/extensions/resource-deployment/src/helpers/promise.ts b/extensions/resource-deployment/src/helpers/promise.ts new file mode 100644 index 0000000000..53f62a287b --- /dev/null +++ b/extensions/resource-deployment/src/helpers/promise.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Deferred promise + */ +export class Deferred { + promise: Promise; + resolve!: (value?: T | PromiseLike) => void; + reject!: (reason?: any) => void; + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + } + + then(onfulfilled?: (value: T) => TResult | Thenable, onrejected?: (reason: any) => TResult | Thenable): Thenable; + then(onfulfilled?: (value: T) => TResult | Thenable, onrejected?: (reason: any) => void): Thenable; + then(onfulfilled?: (value: T) => TResult | Thenable, onrejected?: (reason: any) => TResult | Thenable): Thenable { + return this.promise.then(onfulfilled, onrejected); + } +} diff --git a/extensions/resource-deployment/src/interfaces.ts b/extensions/resource-deployment/src/interfaces.ts index 1d6a4b7b36..c8d522cd45 100644 --- a/extensions/resource-deployment/src/interfaces.ts +++ b/extensions/resource-deployment/src/interfaces.ts @@ -5,6 +5,7 @@ import * as azdata from 'azdata'; import * as vscode from 'vscode'; +import { OptionsSource, OptionsSourceType } from './helpers/optionSources'; export const NoteBookEnvironmentVariablePrefix = 'AZDATA_NB_VAR_'; @@ -172,9 +173,18 @@ export type ComponentCSSStyles = { [key: string]: string; }; +export interface IOptionsSource { + readonly type: OptionsSourceType, + readonly variableNames: { [index: string]: string; }, + getOptions(): Promise, + getVariableValue(variableName: string, input: string): Promise; + getIsPassword(variableName: string): boolean; +} + export interface OptionsInfo { - values: string[] | azdata.CategoryValue[], + values?: string[] | azdata.CategoryValue[], + source?: OptionsSource, defaultValue: string, optionsType?: OptionsType } diff --git a/extensions/resource-deployment/src/localizedConstants.ts b/extensions/resource-deployment/src/localizedConstants.ts index 1f9f1fe13e..cac629652b 100644 --- a/extensions/resource-deployment/src/localizedConstants.ts +++ b/extensions/resource-deployment/src/localizedConstants.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import * as nls from 'vscode-nls'; +import { FieldType, OptionsType } from './interfaces'; +import { OptionsSourceType } from './helpers/optionSources'; const localize = nls.loadMessageBundle(); @@ -13,12 +15,22 @@ export const resourceGroup = localize('azure.account.resourceGroup', "Resource G export const location = localize('azure.account.location', "Azure Location"); export const browse = localize('filePicker.browse', "Browse"); export const select = localize('filePicker.select', "Select"); -export const kubeConfigFilePath = localize('kubeConfigClusterPicker.kubeConfigFilePatht', "Kube config file path"); +export const kubeConfigFilePath = localize('kubeConfigClusterPicker.kubeConfigFilePath', "Kube config file path"); export const clusterContextNotFound = localize('kubeConfigClusterPicker.clusterContextNotFound', "No cluster context information found"); export const signIn = localize('azure.signin', "Sign in…"); export const refresh = localize('azure.refresh', "Refresh"); export const createNewResourceGroup = localize('azure.resourceGroup.createNewResourceGroup', "Create a new resource group"); export const NewResourceGroupAriaLabel = localize('azure.resourceGroup.NewResourceGroupAriaLabel', "New resource group name"); export const realm = localize('deployCluster.Realm', "Realm"); - - +export const unexpectedOptionsSourceType = (type: OptionsSourceType) => localize('optionsSourceType.Invalid', "Invalid options source type:{0}", type); +export const unknownFieldTypeError = (type: FieldType) => localize('UnknownFieldTypeError', "Unknown field type: \"{0}\"", type); +export const variableValueFetchForUnsupportedVariable = (variableName: string) => localize('getVariableValue.unknownVariableName', "Attempt to get variable value for unknown variable:{0}", variableName); +export const isPasswordFetchForUnsupportedVariable = (variableName: string) => localize('getIsPassword.unknownVariableName', "Attempt to get isPassword for unknown variable:{0}", variableName); +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 noOptionsSourceDefined = (optionsSourceType: OptionsSourceType) => localize('noOptionsSourceDefined', "No OptionsSource defined for type: {0}", optionsSourceType); +export const noControllerInfoFound = (name: string) => localize('noControllerInfoFound', "controllerInfo could not be found with name: {0}", name); +export const noPasswordFound = (controllerName: string) => localize('noPasswordFound', "Password could not be retrieved for controller: {0} and user did not provide a password. Please retry later.", controllerName); +export const optionsNotDefined = (fieldType: FieldType) => localize('optionsNotDefined', "FieldInfo.options was not defined for field type: {0}", fieldType); +export const optionsNotObjectOrArray = localize('optionsNotObjectOrArray', "FieldInfo.options must be an object if it is not an array"); +export const optionsTypeNotFound = localize('optionsTypeNotFound', "When FieldInfo.options is an object it must have 'optionsType' property"); +export const optionsTypeRadioOrDropdown = localize('optionsTypeRadioOrDropdown', "When optionsType is not {0} then it must be {1}", OptionsType.Radio, OptionsType.Dropdown); diff --git a/extensions/resource-deployment/src/services/apiService.ts b/extensions/resource-deployment/src/services/apiService.ts index da06eaca4e..1df81c4baa 100644 --- a/extensions/resource-deployment/src/services/apiService.ts +++ b/extensions/resource-deployment/src/services/apiService.ts @@ -5,26 +5,17 @@ import * as azurecore from 'azurecore'; import * as vscode from 'vscode'; +import * as arc from 'arc'; export interface IApiService { - getAzurecoreApi(): Promise; + readonly azurecoreApi: azurecore.IExtension; + readonly arcApi: arc.IExtension; } class ApiService implements IApiService { - - private azurecoreApi: azurecore.IExtension | undefined; - constructor() { } - - public async getAzurecoreApi(): Promise { - if (!this.azurecoreApi) { - this.azurecoreApi = (await vscode.extensions.getExtension(azurecore.extension.name)?.activate()); - if (!this.azurecoreApi) { - throw new Error('Unable to retrieve azurecore API'); - } - } - return this.azurecoreApi; - } + public get azurecoreApi() { return vscode.extensions.getExtension(azurecore.extension.name)?.exports; } + public get arcApi() { return vscode.extensions.getExtension(arc.extension.name)?.exports; } } export const apiService: IApiService = new ApiService(); diff --git a/extensions/resource-deployment/src/services/arcService.ts b/extensions/resource-deployment/src/services/arcService.ts deleted file mode 100644 index 7f912bb260..0000000000 --- a/extensions/resource-deployment/src/services/arcService.ts +++ /dev/null @@ -1,17 +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 * as arc from 'arc'; -import * as vscode from 'vscode'; - -export class ArcService { - private _arcApi: arc.IExtension; - constructor() { - this._arcApi = vscode.extensions.getExtension(arc.extension.name)?.exports; - } - - public async getRegisteredDataControllers(): Promise { - return await this._arcApi.getRegisteredDataControllers(); - } -} diff --git a/extensions/resource-deployment/src/test/apiService.test.ts b/extensions/resource-deployment/src/test/apiService.test.ts index 3a2961a635..c1b7b8181f 100644 --- a/extensions/resource-deployment/src/test/apiService.test.ts +++ b/extensions/resource-deployment/src/test/apiService.test.ts @@ -9,8 +9,8 @@ import { apiService } from '../services/apiService'; suite('API Service Tests', function (): void { - test('getAzurecoreApi returns azure api', async () => { - const api = await apiService.getAzurecoreApi(); + test('getAzurecoreApi returns azure api', () => { + const api = apiService.azurecoreApi; assert(api !== undefined); }); }); diff --git a/extensions/resource-deployment/src/ui/deployClusterWizard/pages/azureSettingsPage.ts b/extensions/resource-deployment/src/ui/deployClusterWizard/pages/azureSettingsPage.ts index c71e4f818f..b544ce3f07 100644 --- a/extensions/resource-deployment/src/ui/deployClusterWizard/pages/azureSettingsPage.ts +++ b/extensions/resource-deployment/src/ui/deployClusterWizard/pages/azureSettingsPage.ts @@ -176,11 +176,11 @@ export class AzureSettingsPage extends WizardPageBase { }); } - public onLeave(): void { + public async onLeave(): Promise { this.wizard.wizardObject.registerNavigationValidator((pcInfo) => { return true; }); - setModelValues(this.inputComponents, this.wizard.model); + await setModelValues(this.inputComponents, this.wizard.model); Object.assign(this.wizard.inputComponents, this.inputComponents); } } diff --git a/extensions/resource-deployment/src/ui/deployClusterWizard/pages/clusterSettingsPage.ts b/extensions/resource-deployment/src/ui/deployClusterWizard/pages/clusterSettingsPage.ts index b58e334b26..f10da57b52 100644 --- a/extensions/resource-deployment/src/ui/deployClusterWizard/pages/clusterSettingsPage.ts +++ b/extensions/resource-deployment/src/ui/deployClusterWizard/pages/clusterSettingsPage.ts @@ -289,8 +289,8 @@ export class ClusterSettingsPage extends WizardPageBase { }); } - public onLeave() { - setModelValues(this.inputComponents, this.wizard.model); + public async onLeave(): Promise { + await setModelValues(this.inputComponents, this.wizard.model); Object.assign(this.wizard.inputComponents, this.inputComponents); if (this.wizard.model.authenticationMode === AuthenticationMode.ActiveDirectory) { const variableDNSPrefixMapping: { [s: string]: string } = {}; diff --git a/extensions/resource-deployment/src/ui/deployClusterWizard/pages/deploymentProfilePage.ts b/extensions/resource-deployment/src/ui/deployClusterWizard/pages/deploymentProfilePage.ts index 088d2a47e3..09ded8bc10 100644 --- a/extensions/resource-deployment/src/ui/deployClusterWizard/pages/deploymentProfilePage.ts +++ b/extensions/resource-deployment/src/ui/deployClusterWizard/pages/deploymentProfilePage.ts @@ -233,7 +233,7 @@ export class DeploymentProfilePage extends WizardPageBase { }); } - public onLeave() { + public async onLeave(): Promise { this.wizard.wizardObject.registerNavigationValidator((pcInfo) => { return true; }); diff --git a/extensions/resource-deployment/src/ui/deployClusterWizard/pages/serviceSettingsPage.ts b/extensions/resource-deployment/src/ui/deployClusterWizard/pages/serviceSettingsPage.ts index 33c63941aa..553bd2d0a1 100644 --- a/extensions/resource-deployment/src/ui/deployClusterWizard/pages/serviceSettingsPage.ts +++ b/extensions/resource-deployment/src/ui/deployClusterWizard/pages/serviceSettingsPage.ts @@ -400,8 +400,8 @@ export class ServiceSettingsPage extends WizardPageBase { }); } - public onLeave(): void { - setModelValues(this.inputComponents, this.wizard.model); + public async onLeave(): Promise { + await setModelValues(this.inputComponents, this.wizard.model); Object.assign(this.wizard.inputComponents, this.inputComponents); this.wizard.wizardObject.registerNavigationValidator((pcInfo) => { return true; diff --git a/extensions/resource-deployment/src/ui/deployClusterWizard/pages/summaryPage.ts b/extensions/resource-deployment/src/ui/deployClusterWizard/pages/summaryPage.ts index 67191cd8f7..1d1639bef6 100644 --- a/extensions/resource-deployment/src/ui/deployClusterWizard/pages/summaryPage.ts +++ b/extensions/resource-deployment/src/ui/deployClusterWizard/pages/summaryPage.ts @@ -326,7 +326,7 @@ export class SummaryPage extends WizardPageBase { this.form.addFormItems(this.formItems); } - public onLeave() { + public async onLeave(): Promise { this.wizard.hideCustomButtons(); this.wizard.wizardObject.message = { text: '' }; } diff --git a/extensions/resource-deployment/src/ui/deployClusterWizard/pages/targetClusterPage.ts b/extensions/resource-deployment/src/ui/deployClusterWizard/pages/targetClusterPage.ts index b7b4c06525..19f7560f86 100644 --- a/extensions/resource-deployment/src/ui/deployClusterWizard/pages/targetClusterPage.ts +++ b/extensions/resource-deployment/src/ui/deployClusterWizard/pages/targetClusterPage.ts @@ -74,7 +74,7 @@ export class TargetClusterContextPage extends WizardPageBase { this.wizard.wizardObject.registerNavigationValidator((e) => { return true; }); diff --git a/extensions/resource-deployment/src/ui/deploymentInputDialog.ts b/extensions/resource-deployment/src/ui/deploymentInputDialog.ts index 3999ab5224..e30447b7b2 100644 --- a/extensions/resource-deployment/src/ui/deploymentInputDialog.ts +++ b/extensions/resource-deployment/src/ui/deploymentInputDialog.ts @@ -94,9 +94,9 @@ export class DeploymentInputDialog extends DialogBase { }); } - protected onComplete(): void { + protected async onComplete(): Promise { const model: Model = new Model(); - setModelValues(this.inputComponents, model); + await setModelValues(this.inputComponents, model); if (instanceOfNotebookBasedDialogInfo(this.dialogInfo)) { model.setEnvironmentVariables(); if (this.dialogInfo.runNotebook) { @@ -110,7 +110,7 @@ export class DeploymentInputDialog extends DialogBase { }); } } else { - vscode.commands.executeCommand(this.dialogInfo.command, model); + await vscode.commands.executeCommand(this.dialogInfo.command, model); } } diff --git a/extensions/resource-deployment/src/ui/dialogBase.ts b/extensions/resource-deployment/src/ui/dialogBase.ts index 503e1f9983..b68f995161 100644 --- a/extensions/resource-deployment/src/ui/dialogBase.ts +++ b/extensions/resource-deployment/src/ui/dialogBase.ts @@ -27,12 +27,12 @@ export abstract class DialogBase { this.dispose(); } - private onOkButtonClicked(): void { - this.onComplete(); + private async onOkButtonClicked(): Promise { + await this.onComplete(); this.dispose(); } - protected onComplete(): void { + protected async onComplete(): Promise { } protected dispose(): void { diff --git a/extensions/resource-deployment/src/ui/modelViewUtils.ts b/extensions/resource-deployment/src/ui/modelViewUtils.ts index b23c0d1cd9..8bd05ccaa3 100644 --- a/extensions/resource-deployment/src/ui/modelViewUtils.ts +++ b/extensions/resource-deployment/src/ui/modelViewUtils.ts @@ -3,27 +3,28 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as azdata from 'azdata'; +import { azureResource } from 'azureResource'; import * as fs from 'fs'; import { EOL, homedir as os_homedir } from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; import * as nls from 'vscode-nls'; -import { azureResource } from 'azureResource'; +import { OptionsSource } from '../helpers/optionSources'; import { AzureAccountFieldInfo, AzureLocationsFieldInfo, ComponentCSSStyles, DialogInfoBase, FieldInfo, FieldType, FilePickerFieldInfo, KubeClusterContextFieldInfo, LabelPosition, NoteBookEnvironmentVariablePrefix, OptionsInfo, OptionsType, PageInfoBase, RowInfo, SectionInfo, TextCSSStyles } from '../interfaces'; import * as loc from '../localizedConstants'; import { apiService } from '../services/apiService'; import { getDefaultKubeConfigPath, getKubeConfigClusterContexts } from '../services/kubeService'; -import { assert, getDateTimeString, getErrorMessage } from '../utils'; +import { KubeCtlTool, KubeCtlToolName } from '../services/tools/kubeCtlTool'; +import { IToolsService } from '../services/toolsService'; +import { getDateTimeString, getErrorMessage, throwUnless } from '../utils'; import { WizardInfoBase } from './../interfaces'; import { Model } from './model'; import { RadioGroupLoadingComponentBuilder } from './radioGroupLoadingComponentBuilder'; -import { IToolsService } from '../services/toolsService'; -import { KubeCtlToolName, KubeCtlTool } from '../services/tools/kubeCtlTool'; const localize = nls.loadMessageBundle(); export type Validator = () => { valid: boolean, message: string }; -export type InputValueTransformer = (inputValue: string) => string; +export type InputValueTransformer = (inputValue: string) => string | Promise; export type InputComponent = azdata.TextComponent | azdata.InputBoxComponent | azdata.DropDownComponent | azdata.CheckBoxComponent | RadioGroupLoadingComponentBuilder; export type InputComponentInfo = { component: InputComponent; @@ -353,7 +354,7 @@ function addLabelInputPairToContainer(view: azdata.ModelView, components: azdata async function processField(context: FieldContext): Promise { switch (context.fieldInfo.type) { case FieldType.Options: - processOptionsTypeField(context); + await processOptionsTypeField(context); break; case FieldType.DateTimeText: processDateTimeTextField(context); @@ -390,12 +391,23 @@ async function processField(context: FieldContext): Promise { await processKubeStorageClassField(context); break; default: - throw new Error(localize('UnknownFieldTypeError', "Unknown field type: \"{0}\"", context.fieldInfo.type)); + throw new Error(loc.unknownFieldTypeError(context.fieldInfo.type)); } } -function processOptionsTypeField(context: FieldContext): void { - assert(context.fieldInfo.options !== undefined, `FieldInfo.options must be defined for FieldType:${FieldType.Options}`); +function disableControlButtons(container: azdata.window.Dialog | azdata.window.Wizard): void { + if ('okButton' in container) { + container.okButton.enabled = false; + } else { + container.doneButton.enabled = false; + container.nextButton.enabled = false; + container.backButton.enabled = false; + container.customButtons.forEach(b => b.enabled = false); + } +} + +async function processOptionsTypeField(context: FieldContext): Promise { + throwUnless(context.fieldInfo.options !== undefined, loc.optionsNotDefined(context.fieldInfo.type)); if (Array.isArray(context.fieldInfo.options)) { context.fieldInfo.options = { values: context.fieldInfo.options, @@ -403,17 +415,62 @@ function processOptionsTypeField(context: FieldContext): void { optionsType: OptionsType.Dropdown }; } - assert(typeof context.fieldInfo.options === 'object', `FieldInfo.options must be an object if it is not an array`); - assert('optionsType' in context.fieldInfo.options, `When FieldInfo.options is an object it must have 'optionsType' property`); + throwUnless(typeof context.fieldInfo.options === 'object', loc.optionsNotObjectOrArray); + throwUnless('optionsType' in context.fieldInfo.options, loc.optionsTypeNotFound); + if (context.fieldInfo.options.source) { + try { + // if options.source still points to the IOptionsSource interface make it to point to the implementation + context.fieldInfo.options.source = OptionsSource.construct(context.fieldInfo.options.source.type, context.fieldInfo.options.source.variableNames); + context.fieldInfo.options.values = await context.fieldInfo.options.source.getOptions(); + } + catch (e) { + disableControlButtons(context.container); + context.container.message = { + text: getErrorMessage(e), + description: '', + level: azdata.window.MessageLevel.Error + }; + context.fieldInfo.options.values = []; + } + context.fieldInfo.subFields = context.fieldInfo.subFields || []; + } + let optionsComponent: InputComponent; if (context.fieldInfo.options.optionsType === OptionsType.Radio) { - processRadioOptionsTypeField(context); + optionsComponent = await processRadioOptionsTypeField(context); } else { - assert(context.fieldInfo.options.optionsType === OptionsType.Dropdown, `When optionsType is not ${OptionsType.Radio} then it must be ${OptionsType.Dropdown}`); - processDropdownOptionsTypeField(context); + throwUnless(context.fieldInfo.options.optionsType === OptionsType.Dropdown, loc.optionsTypeRadioOrDropdown); + optionsComponent = processDropdownOptionsTypeField(context); + } + if (context.fieldInfo.options.source) { + const optionsSource = context.fieldInfo.options.source; + for (const key of Object.keys(optionsSource.variableNames ?? {})) { + context.fieldInfo.subFields!.push({ + label: context.fieldInfo.label, + variableName: optionsSource.variableNames[key] + }); + context.onNewInputComponentCreated(optionsSource.variableNames[key], { + component: optionsComponent, + inputValueTransformer: async (controllerName: string) => { + try { + const variableValue = await optionsSource.getVariableValue(key, controllerName); + return variableValue; + } catch (e) { + disableControlButtons(context.container); + context.container.message = { + text: getErrorMessage(e), + description: '', + level: azdata.window.MessageLevel.Error + }; + return ''; + } + }, + isPassword: optionsSource.getIsPassword(key) + }); + } } } -function processDropdownOptionsTypeField(context: FieldContext): void { +function processDropdownOptionsTypeField(context: FieldContext): azdata.DropDownComponent { const label = createLabel(context.view, { text: context.fieldInfo.label, description: context.fieldInfo.description, required: context.fieldInfo.required, width: context.fieldInfo.labelWidth, cssStyles: context.fieldInfo.labelCSSStyles }); const options = context.fieldInfo.options as OptionsInfo; const dropdown = createDropdown(context.view, { @@ -427,6 +484,7 @@ function processDropdownOptionsTypeField(context: FieldContext): void { dropdown.fireOnTextChange = true; context.onNewInputComponentCreated(context.fieldInfo.variableName!, { component: dropdown }); addLabelInputPairToContainer(context.view, context.components, label, dropdown, context.fieldInfo); + return dropdown; } function processDateTimeTextField(context: FieldContext): void { @@ -579,8 +637,8 @@ function processEvaluatedTextField(context: FieldContext): ReadOnlyFieldInputs { const readOnlyField = processReadonlyTextField(context, false /*allowEvaluation*/); context.onNewInputComponentCreated(context.fieldInfo.variableName || context.fieldInfo.label, { component: readOnlyField.text!, - inputValueTransformer: () => { - readOnlyField.text!.value = substituteVariableValues(context.inputComponents, context.fieldInfo.defaultValue); + inputValueTransformer: async () => { + readOnlyField.text!.value = await substituteVariableValues(context.inputComponents, context.fieldInfo.defaultValue); return readOnlyField.text?.value!; } }); @@ -596,14 +654,15 @@ function processEvaluatedTextField(context: FieldContext): ReadOnlyFieldInputs { * @param inputValue * @param inputComponents */ -function substituteVariableValues(inputComponents: InputComponents, inputValue?: string): string | undefined { - Object.keys(inputComponents) +async function substituteVariableValues(inputComponents: InputComponents, inputValue?: string): Promise { + await Promise.all(Object.keys(inputComponents) .filter(key => key.startsWith(NoteBookEnvironmentVariablePrefix)) - .forEach(key => { - const value = getInputComponentValue(inputComponents, key) ?? ''; + .map(async key => { + const value = (await getInputComponentValue(inputComponents, key)) ?? ''; const re: RegExp = new RegExp(`\\\$\\\(${key}\\\)`, 'gi'); inputValue = inputValue?.replace(re, value); - }); + }) + ); return inputValue; } @@ -984,7 +1043,7 @@ async function handleSelectedAccountChanged( } try { - const response = await (await apiService.getAzurecoreApi()).getSubscriptions(selectedAccount, true); + const response = await apiService.azurecoreApi.getSubscriptions(selectedAccount, true); if (!response) { return; } @@ -1099,7 +1158,7 @@ async function handleSelectedSubscriptionChanged(context: AzureAccountFieldConte return; } try { - const response = await (await apiService.getAzurecoreApi()).getResourceGroups(selectedAccount, selectedSubscription, true); + const response = await apiService.azurecoreApi.getResourceGroups(selectedAccount, selectedSubscription, true); if (!response) { return; } @@ -1150,8 +1209,7 @@ async function processAzureLocationsField(context: AzureLocationsFieldContext): width: context.fieldInfo.labelWidth, cssStyles: context.fieldInfo.labelCSSStyles }); - const azurecoreApi = await apiService.getAzurecoreApi(); - const locationValues = context.fieldInfo.locations?.map(l => { return { name: l, displayName: azurecoreApi.getRegionDisplayName(l) }; }); + const locationValues = context.fieldInfo.locations?.map(l => { return { name: l, displayName: apiService.azurecoreApi.getRegionDisplayName(l) }; }); const locationDropdown = createDropdown(context.view, { defaultValue: locationValues?.find(l => l.name === context.fieldInfo.defaultValue), width: context.fieldInfo.inputWidth, @@ -1174,7 +1232,7 @@ async function processAzureLocationsField(context: AzureLocationsFieldContext): label: label.value!, variableName: context.fieldInfo.displayLocationVariableName }); - context.onNewInputComponentCreated(context.fieldInfo.displayLocationVariableName, { component: locationDropdown, inputValueTransformer: (value => azurecoreApi.getRegionDisplayName(value)) }); + context.onNewInputComponentCreated(context.fieldInfo.displayLocationVariableName, { component: locationDropdown, inputValueTransformer: (value => apiService.azurecoreApi.getRegionDisplayName(value)) }); } addLabelInputPairToContainer(context.view, context.components, label, locationDropdown, context.fieldInfo); return locationDropdown; @@ -1207,14 +1265,14 @@ export function getPasswordMismatchMessage(fieldName: string): string { return localize('passwordNotMatch', "{0} doesn't match the confirmation password", fieldName); } -export function setModelValues(inputComponents: InputComponents, model: Model): void { - Object.keys(inputComponents).forEach(key => { - const value = getInputComponentValue(inputComponents, key); +export async function setModelValues(inputComponents: InputComponents, model: Model): Promise { + await Promise.all(Object.keys(inputComponents).map(async key => { + const value = await getInputComponentValue(inputComponents, key); model.setPropertyValue(key, value); - }); + })); } -function getInputComponentValue(inputComponents: InputComponents, key: string): string | undefined { +async function getInputComponentValue(inputComponents: InputComponents, key: string): Promise { const input = inputComponents[key].component; if (input === undefined) { return undefined; @@ -1236,7 +1294,10 @@ function getInputComponentValue(inputComponents: InputComponents, key: string): } const inputValueTransformer = inputComponents[key].inputValueTransformer; if (inputValueTransformer) { - value = inputValueTransformer(value || ''); + value = inputValueTransformer(value ?? ''); + if (typeof value !== 'string') { + value = await value; + } } return value; } diff --git a/extensions/resource-deployment/src/ui/notebookWizard/notebookWizard.ts b/extensions/resource-deployment/src/ui/notebookWizard/notebookWizard.ts index 0d7ca0ffd5..6e03ae243b 100644 --- a/extensions/resource-deployment/src/ui/notebookWizard/notebookWizard.ts +++ b/extensions/resource-deployment/src/ui/notebookWizard/notebookWizard.ts @@ -58,7 +58,7 @@ export class NotebookWizard extends WizardBase { - setModelValues(this.inputComponents, this.model); + await setModelValues(this.inputComponents, this.model); const env: NodeJS.ProcessEnv = {}; this.model.setEnvironmentVariables(env, (varName) => { const isPassword = !!this.inputComponents[varName]?.isPassword; diff --git a/extensions/resource-deployment/src/ui/notebookWizard/notebookWizardAutoSummaryPage.ts b/extensions/resource-deployment/src/ui/notebookWizard/notebookWizardAutoSummaryPage.ts index 21b21d02ae..5071399f8a 100644 --- a/extensions/resource-deployment/src/ui/notebookWizard/notebookWizardAutoSummaryPage.ts +++ b/extensions/resource-deployment/src/ui/notebookWizard/notebookWizardAutoSummaryPage.ts @@ -33,7 +33,7 @@ export class NotebookWizardAutoSummaryPage extends NotebookWizardPage { }); } - public onLeave(): void { + public async onLeave(): Promise { this.wizard.wizardObject.message = { text: '' }; } diff --git a/extensions/resource-deployment/src/ui/notebookWizard/notebookWizardPage.ts b/extensions/resource-deployment/src/ui/notebookWizard/notebookWizardPage.ts index af688639a9..524d2adbcb 100644 --- a/extensions/resource-deployment/src/ui/notebookWizard/notebookWizardPage.ts +++ b/extensions/resource-deployment/src/ui/notebookWizard/notebookWizardPage.ts @@ -57,7 +57,7 @@ export class NotebookWizardPage extends WizardPageBase { }); } - public onLeave(): void { + public async onLeave(): Promise { // The following callback registration clears previous navigation validators. this.wizard.wizardObject.registerNavigationValidator((pcInfo) => { return true; @@ -66,7 +66,7 @@ export class NotebookWizardPage extends WizardPageBase { public async onEnter(): Promise { if (this.pageInfo.isSummaryPage) { - setModelValues(this.wizard.inputComponents, this.wizard.model); + await setModelValues(this.wizard.inputComponents, this.wizard.model); } this.wizard.wizardObject.registerNavigationValidator((pcInfo) => { diff --git a/extensions/resource-deployment/src/ui/resourceTypePickerDialog.ts b/extensions/resource-deployment/src/ui/resourceTypePickerDialog.ts index 76b4409d73..953655be14 100644 --- a/extensions/resource-deployment/src/ui/resourceTypePickerDialog.ts +++ b/extensions/resource-deployment/src/ui/resourceTypePickerDialog.ts @@ -355,7 +355,7 @@ export class ResourceTypePickerDialog extends DialogBase { return this._selectedResourceType.getProvider(options)!; } - protected onComplete(): void { + protected async onComplete(): Promise { this.toolsService.toolsForCurrentProvider = this._tools; this.resourceTypeService.startDeployment(this.getCurrentProvider()); } diff --git a/extensions/resource-deployment/src/ui/wizardBase.ts b/extensions/resource-deployment/src/ui/wizardBase.ts index 0db589a26c..ab8f3ebd85 100644 --- a/extensions/resource-deployment/src/ui/wizardBase.ts +++ b/extensions/resource-deployment/src/ui/wizardBase.ts @@ -31,7 +31,7 @@ export abstract class WizardBase, M extends Model this.toDispose.push(this.wizardObject.onPageChanged(async (e) => { let previousPage = this.pages[e.lastPage]; let newPage = this.pages[e.newPage]; - previousPage.onLeave(); + await previousPage.onLeave(); await newPage.onEnter(); })); diff --git a/extensions/resource-deployment/src/ui/wizardPageBase.ts b/extensions/resource-deployment/src/ui/wizardPageBase.ts index 6cc3e30492..2a317b78dc 100644 --- a/extensions/resource-deployment/src/ui/wizardPageBase.ts +++ b/extensions/resource-deployment/src/ui/wizardPageBase.ts @@ -25,7 +25,7 @@ export abstract class WizardPageBase { public async onEnter(): Promise { } - public onLeave(): void { } + public async onLeave(): Promise { } public abstract initialize(): void; diff --git a/extensions/resource-deployment/src/utils.ts b/extensions/resource-deployment/src/utils.ts index 61e1e31e24..9f3a03cddb 100644 --- a/extensions/resource-deployment/src/utils.ts +++ b/extensions/resource-deployment/src/utils.ts @@ -40,7 +40,15 @@ export function setEnvironmentVariablesForInstallPaths(tools: ITool[], env: Node } } -export function assert(condition: boolean, message?: string): asserts condition { +/** + * Throws an Error with given {@link message} unless {@link condition} is true. + * This also tells the typescript compiler that the condition is 'truthy' in the remainder of the scope + * where this function was called. + * + * @param condition + * @param message + */ +export function throwUnless(condition: boolean, message?: string): asserts condition { if (!condition) { throw new Error(message); } diff --git a/samples/sample-resource-deployment/package.json b/samples/sample-resource-deployment/package.json index 5e32ca0ed8..1a6cf89852 100644 --- a/samples/sample-resource-deployment/package.json +++ b/samples/sample-resource-deployment/package.json @@ -91,6 +91,28 @@ "optionsType": "radio" } }, + { + "type": "options", + "label": "%wizard.data.controllers%", + "required": true, + "variableName": "AZDATA_NB_VAR_CONTROLLER", + "editable": false, + "options": { + "source": { + "type": "ArcControllersOptionsSource", + "variableNames": { + "endpoint": "AZDATA_NB_VAR_CONTROLLER_ENDPOINT", + "username": "AZDATA_NB_VAR_CONTROLLER_USERNAME", + "password": "AZDATA_NB_VAR_CONTROLLER_PASSWORD" + } + }, + "values":[ + "ignored1", + "ignored2" + ], + "optionsType": "dropdown" + } + }, { "label": "%wizard.dropdown.options.field%", "variableName": "AZDATA_NB_VAR_DROPDOWN_OPTIONS", @@ -245,7 +267,37 @@ "type": "readonly_text", "isEvaluated": true, "defaultValue": "$(AZDATA_NB_VAR_PROFILE)" - } + }, + { + "label": "%wizard.dropdown.options.field%", + "type": "readonly_text", + "isEvaluated": true, + "defaultValue": "$(AZDATA_NB_VAR_DROPDOWN_OPTIONS)" + }, + { + "label": "%wizard.summary.controller%", + "type": "readonly_text", + "isEvaluated": true, + "defaultValue": "$(AZDATA_NB_VAR_CONTROLLER)" + }, + { + "label": "%wizard.summary.controller.endpoint%", + "type": "readonly_text", + "isEvaluated": true, + "defaultValue": "$(AZDATA_NB_VAR_CONTROLLER_ENDPOINT)" + }, + { + "label": "%wizard.summary.controller.username%", + "type": "readonly_text", + "isEvaluated": true, + "defaultValue": "$(AZDATA_NB_VAR_CONTROLLER_USERNAME)" + }, + { + "label": "%wizard.summary.controller.password%", + "type": "readonly_text", + "isEvaluated": true, + "defaultValue": "$(AZDATA_NB_VAR_CONTROLLER_PASSWORD)" + } ] } ] diff --git a/samples/sample-resource-deployment/package.nls.json b/samples/sample-resource-deployment/package.nls.json index 126d4ab84e..ed07558764 100644 --- a/samples/sample-resource-deployment/package.nls.json +++ b/samples/sample-resource-deployment/package.nls.json @@ -28,6 +28,7 @@ "wizard.kube.cluster.context": "Cluster context", "wizard.cluster.config.profile.title": "Choose the config profile", "wizard.cluster.config.profile": "Config profile", + "wizard.data.controllers": "Pick a data controller", "wizard.dropdown.options.field": "dropdown field", "wizard.project.details.title": "Project details", "wizard.project.details.description": "Project details for Contoso corporation", @@ -44,6 +45,10 @@ "wizard.summary.kube.config.file.path": "Kube config file path", "wizard.summary.cluster.context": "Cluster context", "wizard.summary.profile": "Config profile", + "wizard.summary.controller": "Controller", + "wizard.summary.controller.endpoint": "Controller endpoint", + "wizard.summary.controller.username": "Controller username", + "wizard.summary.controller.password": "Controller password", "wizard.data.controller.agreement": "I accept {0} and {1}.", "contoso.agreement.privacy.statement":"contoso Privacy Statement", "wizard.agreement.contosoCmd.eula":"contoso cmd license terms",