diff --git a/extensions/arc/notebooks/arcDeployment/deploy.postgres.existing.arc.ipynb b/extensions/arc/notebooks/arcDeployment/deploy.postgres.existing.arc.ipynb index 2fca7b8679..37ae3fabe1 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", @@ -154,6 +174,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": [ @@ -177,4 +212,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 2bbb215f73..02109c1baa 100644 --- a/extensions/arc/package.json +++ b/extensions/arc/package.json @@ -547,20 +547,7 @@ ], "when": true } - ], - "agreement": { - "template": "%arc.control.plane.arc.data.controller.agreement%", - "links": [ - { - "text": "%microsoft.agreement.privacy.statement%", - "url": "https://go.microsoft.com/fwlink/?LinkId=853010" - }, - { - "text": "%arc.agreement.azdata.eula%", - "url": "https://aka.ms/eula-azdata-en" - } - ] - } + ] }, { "name": "arc.sql", @@ -571,18 +558,6 @@ "light": "./images/miaa.svg", "dark": "./images/miaa.svg" }, - "options": [ - { - "name": "resourceType", - "displayName": "%resource.type.picker.display.name%", - "values": [ - { - "name": "sql.managed.instance", - "displayName": "%sql.managed.instance.display.name%" - } - ] - } - ], "providers": [ { "dialog": { @@ -599,6 +574,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", @@ -653,7 +647,7 @@ "version": "20.1.0" } ], - "when": "resourceType=sql.managed.instance" + "when": "true" } ], "agreement": { @@ -666,10 +660,6 @@ { "text": "%arc.agreement.sql.terms.conditions%", "url": "https://go.microsoft.com/fwlink/?linkid=2045708" - }, - { - "text": "%arc.agreement.azdata.eula%", - "url": "https://aka.ms/eula-azdata-en" } ] } @@ -683,18 +673,6 @@ "light": "./images/postgres.svg", "dark": "./images/postgres.svg" }, - "options": [ - { - "name": "resourceType", - "displayName": "%resource.type.picker.display.name%", - "values": [ - { - "name": "postgres", - "displayName": "%postgres.server.group.display.name%" - } - ] - } - ], "providers": [ { "dialog": { @@ -711,6 +689,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", @@ -810,7 +807,7 @@ "version": "20.1.0" } ], - "when": "resourceType=postgres" + "when": "true" } ], "agreement": { @@ -823,10 +820,6 @@ { "text": "%arc.agreement.postgres.terms.conditions%", "url": "https://go.microsoft.com/fwlink/?linkid=2045708" - }, - { - "text": "%arc.agreement.azdata.eula%", - "url": "https://aka.ms/eula-azdata-en" } ] } diff --git a/extensions/arc/package.nls.json b/extensions/arc/package.nls.json index 53a2f83107..263a992c96 100644 --- a/extensions/arc/package.nls.json +++ b/extensions/arc/package.nls.json @@ -65,7 +65,6 @@ "arc.control.plane.summary.location": "Location", "arc.control.plane.arc.data.controller.agreement": "I accept {0} and {1}.", "microsoft.agreement.privacy.statement":"Microsoft Privacy Statement", - "arc.agreement.azdata.eula":"azdata license terms", "deploy.arc.control.plane.action":"Script to notebook", @@ -73,9 +72,9 @@ "resource.type.arc.postgres.display.name": "PostgreSQL server groups - Azure Arc (preview)", "resource.type.arc.sql.description": "Managed SQL Instance service for app developers in a customer-managed environment", "resource.type.arc.postgres.description": "Deploy PostgreSQL server groups into an Azure Arc environment", - "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", @@ -108,7 +107,7 @@ "arc.postgres.server.group.cores.limit": "Max CPU cores (per node) to allow", "arc.postgres.server.group.memory.request": "Min memory MB (per node) to reserve", "arc.postgres.server.group.memory.limit": "Max memory MB (per node) to allow", - "arc.agreement": "I accept {0}, {1} and {2}.", + "arc.agreement": "I accept {0} and {1}.", "arc.agreement.sql.terms.conditions":"Azure SQL managed instance - Azure Arc terms and conditions", "arc.agreement.postgres.terms.conditions":"PostgreSQL server groups - Azure Arc terms and conditions", "arc.deploy.action":"Deploy" 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..a14ed3dd2b 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', @@ -24,9 +24,6 @@ const enum AzdataDeployOption { * Interface for an object to interact with the azdata tool installed on the box. */ export interface IAzdataTool extends azdataExt.IAzdataApi { - path: string, - cachedVersion: SemVer - /** * Executes azdata with the specified arguments (e.g. --version) and returns the result * @param args The args to pass to azdata @@ -39,9 +36,26 @@ export interface IAzdataTool extends azdataExt.IAzdataApi { * An object to interact with the azdata tool installed on the box. */ export class AzdataTool implements IAzdataTool { - public cachedVersion: SemVer; - constructor(public path: string, version: string) { - this.cachedVersion = new SemVer(version); + + private _semVersion: SemVer; + constructor(private _path: string, version: string) { + this._semVersion = new SemVer(version); + } + + /** + * The semVersion corresponding to this installation of azdata. version() method should have been run + * before fetching this value to ensure that correct value is returned. This is almost always correct unless + * Azdata has gotten reinstalled in the background after this IAzdataApi object was constructed. + */ + public getSemVersion() { + return this._semVersion; + } + + /** + * gets the path where azdata tool is installed + */ + public getPath() { + return this._path; } public arc = { @@ -110,8 +124,8 @@ export class AzdataTool implements IAzdataTool { * It also updates the cachedVersion property based on the return value from the tool. */ public async version(): Promise> { - const output = await executeAzdataCommand(`"${this.path}"`, ['--version']); - this.cachedVersion = new SemVer(parseVersion(output.stdout)); + const output = await executeAzdataCommand(`"${this._path}"`, ['--version']); + this._semVersion = new SemVer(parseVersion(output.stdout)); return { logs: [], stdout: output.stdout.split(os.EOL), @@ -122,7 +136,7 @@ export class AzdataTool implements IAzdataTool { public async executeCommand(args: string[], additionalEnvVars?: { [key: string]: string }): Promise> { try { - const output = JSON.parse((await executeAzdataCommand(`"${this.path}"`, args.concat(['--output', 'json']), additionalEnvVars)).stdout); + const output = JSON.parse((await executeAzdataCommand(`"${this._path}"`, args.concat(['--output', 'json']), additionalEnvVars)).stdout); return { logs: output.log, stdout: output.stdout, @@ -136,12 +150,12 @@ 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. try { - await fs.promises.access(this.path); + await fs.promises.access(this._path); //this.path exists throw err; // rethrow the error } catch (e) { @@ -176,7 +190,7 @@ export async function findAzdata(): Promise { try { const azdata = await findSpecificAzdata(); await vscode.commands.executeCommand('setContext', azdataFound, true); // save a context key that azdata was found so that command for installing azdata is no longer available in commandPalette and that for updating it is. - Logger.log(loc.foundExistingAzdata(azdata.path, azdata.cachedVersion.raw)); + Logger.log(loc.foundExistingAzdata(azdata.getPath(), azdata.getSemVersion().raw)); return azdata; } catch (err) { Logger.log(loc.couldNotFindAzdata(err)); @@ -262,12 +276,12 @@ export async function checkAndInstallAzdata(userRequested: boolean = false): Pro */ export async function checkAndUpdateAzdata(currentAzdata?: IAzdataTool, userRequested: boolean = false): Promise { if (currentAzdata !== undefined) { - const newVersion = await discoverLatestAvailableAzdataVersion(); - if (newVersion.compare(currentAzdata.cachedVersion) === 1) { - Logger.log(loc.foundAzdataVersionToUpdateTo(newVersion.raw, currentAzdata.cachedVersion.raw)); - return await promptToUpdateAzdata(newVersion.raw, userRequested); + const newSemVersion = await discoverLatestAvailableAzdataVersion(); + if (newSemVersion.compare(currentAzdata.getSemVersion()) === 1) { + Logger.log(loc.foundAzdataVersionToUpdateTo(newSemVersion.raw, currentAzdata.getSemVersion().raw)); + return await promptToUpdateAzdata(newSemVersion.raw, userRequested); } else { - Logger.log(loc.currentlyInstalledVersionIsLatest(currentAzdata.cachedVersion.raw)); + Logger.log(loc.currentlyInstalledVersionIsLatest(currentAzdata.getSemVersion().raw)); } } else { Logger.log(loc.updateCheckSkipped); diff --git a/extensions/azdata/src/extension.ts b/extensions/azdata/src/extension.ts index f769b88812..3a743d2f86 100644 --- a/extensions/azdata/src/extension.ts +++ b/extensions/azdata/src/extension.ts @@ -12,7 +12,6 @@ import * as loc from './localizedConstants'; let localAzdata: IAzdataTool | undefined = undefined; let eulaAccepted: boolean = false; - export async function activate(context: vscode.ExtensionContext): Promise { vscode.commands.registerCommand('azdata.acceptEula', async () => { eulaAccepted = await promptForEula(context.globalState, true /* userRequested */); @@ -59,26 +58,27 @@ export async function activate(context: vscode.ExtensionContext): Promise !!context.globalState.get(constants.eulaAccepted), azdata: { arc: { dc: { create: async (namespace: string, name: string, connectivityMode: string, resourceGroup: string, location: string, subscription: string, profileName?: string, storageClass?: string) => { - await throwIfNoAzdataOrEulaNotAccepted(); + throwIfNoAzdataOrEulaNotAccepted(); return localAzdata!.arc.dc.create(namespace, name, connectivityMode, resourceGroup, location, subscription, profileName, storageClass); }, endpoint: { list: async () => { - await throwIfNoAzdataOrEulaNotAccepted(); + throwIfNoAzdataOrEulaNotAccepted(); return localAzdata!.arc.dc.endpoint.list(); } }, config: { list: async () => { - await throwIfNoAzdataOrEulaNotAccepted(); + throwIfNoAzdataOrEulaNotAccepted(); return localAzdata!.arc.dc.config.list(); }, show: async () => { - await throwIfNoAzdataOrEulaNotAccepted(); + throwIfNoAzdataOrEulaNotAccepted(); return localAzdata!.arc.dc.config.show(); } } @@ -86,11 +86,11 @@ export async function activate(context: vscode.ExtensionContext): Promise { - await throwIfNoAzdataOrEulaNotAccepted(); + throwIfNoAzdataOrEulaNotAccepted(); return localAzdata!.arc.postgres.server.list(); }, show: async (name: string) => { - await throwIfNoAzdataOrEulaNotAccepted(); + throwIfNoAzdataOrEulaNotAccepted(); return localAzdata!.arc.postgres.server.show(name); } } @@ -98,41 +98,53 @@ export async function activate(context: vscode.ExtensionContext): Promise { - await throwIfNoAzdataOrEulaNotAccepted(); + throwIfNoAzdataOrEulaNotAccepted(); return localAzdata!.arc.sql.mi.delete(name); }, list: async () => { - await throwIfNoAzdataOrEulaNotAccepted(); + throwIfNoAzdataOrEulaNotAccepted(); return localAzdata!.arc.sql.mi.list(); }, show: async (name: string) => { - await throwIfNoAzdataOrEulaNotAccepted(); + throwIfNoAzdataOrEulaNotAccepted(); return localAzdata!.arc.sql.mi.show(name); } } } }, + getPath: () => { + throwIfNoAzdata(); + return localAzdata!.getPath(); + }, login: async (endpoint: string, username: string, password: string) => { - await throwIfNoAzdataOrEulaNotAccepted(); + throwIfNoAzdataOrEulaNotAccepted(); return localAzdata!.login(endpoint, username, password); }, + getSemVersion: () => { + throwIfNoAzdata(); + return localAzdata!.getSemVersion(); + }, version: async () => { - await throwIfNoAzdataOrEulaNotAccepted(); + throwIfNoAzdata(); return localAzdata!.version(); } } }; } -async function throwIfNoAzdataOrEulaNotAccepted(): Promise { - if (!localAzdata) { - Logger.log(loc.noAzdata); - throw new Error(loc.noAzdata); - } +function throwIfNoAzdataOrEulaNotAccepted(): void { + throwIfNoAzdata(); if (!eulaAccepted) { Logger.log(loc.eulaNotAccepted); throw new Error(loc.eulaNotAccepted); } } +function throwIfNoAzdata() { + if (!localAzdata) { + Logger.log(loc.noAzdata); + throw new Error(loc.noAzdata); + } +} + export function deactivate(): void { } diff --git a/extensions/azdata/src/localizedConstants.ts b/extensions/azdata/src/localizedConstants.ts index 493f6149ce..bec5ee3e9a 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"); @@ -56,11 +56,11 @@ export const userRequestedInstall = localize('azdata.userRequestedInstall', "Use export const userRequestedUpdate = localize('azdata.userRequestedUpdate', "User requested to update Azure Data CLI using 'Azure Data CLI: Check for Update' command"); export const userRequestedAcceptEula = localize('azdata.acceptEula', "User requested to be prompted for accepting EULA by invoking 'Azure Data CLI: Accept EULA' command"); export const updateCheckSkipped = localize('azdata.updateCheckSkipped', "No check for new Azure Data CLI version availability performed as Azure Data CLI was not found to be installed"); -export const eulaNotAccepted = localize('azdata.eulaNotAccepted', "Microsoft Privacy statement and Azure Data CLI license terms have not been accepted, [accept the EULA](command:azdata.acceptEula) to use the features that require it."); +export const eulaNotAccepted = localize('azdata.eulaNotAccepted', "Microsoft Privacy statement and Azure Data CLI license terms have not been accepted. Execute the command: [Azure Data CLI: Accept EULA](command:azdata.acceptEula) to accept EULA to enable the features that requires Azure Data CLI."); export const installManually = (expectedVersion: string, instructionsUrl: string) => localize('azdata.installManually', "Azure Data CLI is not installed. Version: {0} needs to be installed or some features may not work. Please install it manually using these [instructions]({1}). Restart ADS when installation is done.", expectedVersion, instructionsUrl); export const installCorrectVersionManually = (currentVersion: string, expectedVersion: string, instructionsUrl: string) => localize('azdata.installCorrectVersionManually', "Azure Data CLI version: {0} is installed, version: {1} needs to be installed or some features may not work. Please uninstall the current version and then install the correct version manually using these [instructions]({2}). Restart ADS when installation is done.", currentVersion, expectedVersion, instructionsUrl); export const promptForEula = (privacyStatementUrl: string, eulaUrl: string) => localize('azdata.promptForEula', "It is required to accept the [Microsoft Privacy Statement]({0}) and the [Azure Data CLI license terms]({1}) to use this extension. Declining this will result in some features not working.", privacyStatementUrl, eulaUrl); export const promptForEulaLog = (privacyStatementUrl: string, eulaUrl: string) => promptLog(promptForEula(privacyStatementUrl, eulaUrl)); -export const userResponseToEulaPrompt = (response: string | undefined) => localize('azdata.promptForEulaResponse', "User response to Eula prompt: {0}", response); -export const eulaAcceptedStateOnStartup = (eulaAccepted: boolean) => localize('azdata.eulaAcceptedStateOnStartup', "eulaAccepted state on startup: {0}", eulaAccepted); -export const eulaAcceptedStateUpdated = (eulaAccepted: boolean) => localize('azdata.eulaAcceptedStateUpdated', "Updated 'eulaAccepted' state to: {0}", eulaAccepted); +export const userResponseToEulaPrompt = (response: string | undefined) => localize('azdata.promptForEulaResponse', "User response to EULA prompt: {0}", response); +export const eulaAcceptedStateOnStartup = (eulaAccepted: boolean) => localize('azdata.eulaAcceptedStateOnStartup', "'EULA Accepted' state on startup: {0}", eulaAccepted); +export const eulaAcceptedStateUpdated = (eulaAccepted: boolean) => localize('azdata.eulaAcceptedStateUpdated', "Updated 'EULA Accepted' state to: {0}", eulaAccepted); diff --git a/extensions/azdata/src/test/azdata.test.ts b/extensions/azdata/src/test/azdata.test.ts index c56ea4f2ac..d15414eec7 100644 --- a/extensions/azdata/src/test/azdata.test.ts +++ b/extensions/azdata/src/test/azdata.test.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import * as path from 'path'; -import { SemVer } from 'semver'; import * as should from 'should'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; @@ -12,10 +11,10 @@ import * as azdata from '../azdata'; import * as childProcess from '../common/childProcess'; import { HttpClient } from '../common/httpClient'; import * as utils from '../common/utils'; -import * as loc from '../localizedConstants'; import * as constants from '../constants'; +import * as loc from '../localizedConstants'; -const oldAzdataMock = { path: '/path/to/azdata', cachedVersion: new SemVer('0.0.0') }; +const oldAzdataMock = new azdata.AzdataTool('/path/to/azdata', '0.0.0'); const releaseJson = { win32: { 'version': '9999.999.999', diff --git a/extensions/azdata/src/typings/azdata-ext.d.ts b/extensions/azdata/src/typings/azdata-ext.d.ts index c6f8b45a2e..b87a199f92 100644 --- a/extensions/azdata/src/typings/azdata-ext.d.ts +++ b/extensions/azdata/src/typings/azdata-ext.d.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ declare module 'azdata-ext' { + import { SemVer } from 'semver'; + /** * Covers defining what the azdata extension exports to other extensions * @@ -80,7 +82,7 @@ declare module 'azdata-ext' { location: string, // "eastus2euap", resourceGroup: string, // "my-rg", subscription: string, // "a5082b29-8c6e-4bc5-8ddd-8ef39dfebc39" - }, + }, controller: { 'enableBilling': string, // "True" 'logs.rotation.days': string, // "7" @@ -224,12 +226,20 @@ declare module 'azdata-ext' { show(name: string): Promise> } } - } + }, + getPath(): string, login(endpoint: string, username: string, password: string): Promise>, + /** + * The semVersion corresponding to this installation of azdata. version() method should have been run + * before fetching this value to ensure that correct value is returned. This is almost always correct unless + * Azdata has gotten reinstalled in the background after this IAzdataApi object was constructed. + */ + getSemVersion(): SemVer, version(): Promise> } export interface IExtension { azdata: IAzdataApi; + isEulaAccepted(): boolean; } } diff --git a/extensions/resource-deployment/package.json b/extensions/resource-deployment/package.json index 7a44bea1aa..990bcdb543 100644 --- a/extensions/resource-deployment/package.json +++ b/extensions/resource-deployment/package.json @@ -397,18 +397,20 @@ "dependencies": { "linux-release-info": "^2.0.0", "promisify-child-process": "^3.1.1", + "semver": "^7.3.2", "sudo-prompt": "9.1.1", "vscode-nls": "^4.0.0", "yamljs": "^0.3.0" }, "devDependencies": { "@types/mocha": "^5.2.5", + "@types/semver": "^7.3.1", "@types/yamljs": "0.2.30", "mocha": "^5.2.0", "mocha-junit-reporter": "^1.17.0", "mocha-multi-reporters": "^1.1.7", + "should": "^13.2.3", "typemoq": "^2.1.0", - "vscodetestcover": "^1.1.0", - "should": "^13.2.3" + "vscodetestcover": "^1.1.0" } } 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..4584e8eb46 --- /dev/null +++ b/extensions/resource-deployment/src/helpers/optionSources.ts @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * 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 enum OptionsSourceType { + ArcControllersOptionsSource = 'ArcControllersOptionsSource' +} + +export abstract class OptionsSource implements IOptionsSource { + + 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; + + constructor(private _variableNames: { [index: string]: string }, private _type: OptionsSourceType) { + } +} + +export class ArcControllersOptionsSource extends OptionsSource { + private _cacheManager = new CacheManager(); + + 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)); + } + } +} 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 e94aef2aff..251b103442 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 { OptionsSourceType } from './helpers/optionSources'; export const NoteBookEnvironmentVariablePrefix = 'AZDATA_NB_VAR_'; @@ -171,9 +172,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?: IOptionsSource, defaultValue: string, optionsType?: OptionsType } @@ -363,6 +373,7 @@ export interface ITool { finishInitialization(): Promise; install(): Promise; isSameOrNewerThan(version: string): boolean; + validateEula(): boolean; } export const enum BdcDeploymentType { diff --git a/extensions/resource-deployment/src/localizedConstants.ts b/extensions/resource-deployment/src/localizedConstants.ts index 1f9f1fe13e..0cbf9fc352 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,23 @@ 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: string) => 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); +export const azdataEulaNotAccepted = localize('azdataEulaNotAccepted', "Deployment cannot continue. Azure Data CLI license terms have not been accepted. Execute the command: [Azure Data CLI: Accept EULA] to accept EULA to enable the features that requires Azure Data CLI."); diff --git a/extensions/resource-deployment/src/services/apiService.ts b/extensions/resource-deployment/src/services/apiService.ts index da06eaca4e..fed993131b 100644 --- a/extensions/resource-deployment/src/services/apiService.ts +++ b/extensions/resource-deployment/src/services/apiService.ts @@ -3,28 +3,22 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as arc from 'arc'; +import * as azdataExt from 'azdata-ext'; import * as azurecore from 'azurecore'; import * as vscode from 'vscode'; export interface IApiService { - getAzurecoreApi(): Promise; + readonly azurecoreApi: azurecore.IExtension; + readonly azdataApi: azdataExt.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 azdataApi() { return vscode.extensions.getExtension(azdataExt.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/services/tools/azdataTool.ts b/extensions/resource-deployment/src/services/tools/azdataTool.ts index 8806ccae29..307db0dbe9 100644 --- a/extensions/resource-deployment/src/services/tools/azdataTool.ts +++ b/extensions/resource-deployment/src/services/tools/azdataTool.ts @@ -8,10 +8,11 @@ import { SemVer } from 'semver'; import * as vscode from 'vscode'; import * as nls from 'vscode-nls'; import { AzdataInstallLocationKey, DeploymentConfigurationKey } from '../../constants'; -import { Command, OsDistribution, ToolType } from '../../interfaces'; +import { Command, OsDistribution, ToolStatus, ToolType } from '../../interfaces'; +import { apiService } from '../apiService'; import { IPlatformService } from '../platformService'; import { dependencyType, ToolBase } from './toolBase'; -import { SemVerProxy } from './SemVerProxy'; +import * as loc from '../../localizedConstants'; const localize = nls.loadMessageBundle(); export const AzdataToolName = 'azdata'; @@ -44,25 +45,57 @@ export class AzdataTool extends ToolBase { return 'https://docs.microsoft.com/sql/big-data-cluster/deploy-install-azdata'; } + public validateEula(): boolean { + if (apiService.azdataApi.isEulaAccepted()) { + return true; + } else { + this.setStatusDescription(loc.azdataEulaNotAccepted); + return false; + } + } + + /* unused */ protected get versionCommand(): Command { return { - command: 'azdata -v' + command: '' }; } + /* unused */ protected get discoveryCommand(): Command { return { - command: this.discoveryCommandString('azdata') + command: '' }; } + /** + * updates the version and status for the tool. + */ + protected async updateVersionAndStatus(): Promise { + this.setStatusDescription(''); + await this.addInstallationSearchPathsToSystemPath(); + + const commandOutput = await apiService.azdataApi.azdata.version(); + this.version = apiService.azdataApi.azdata.getSemVersion(); + if (this.version) { + if (this.autoInstallSupported) { + // set the installationPath + this.setInstallationPathOrAdditionalInformation(apiService.azdataApi.azdata.getPath()); + } + this.setStatus(ToolStatus.Installed); + } + else { + this.setInstallationPathOrAdditionalInformation(localize('deployCluster.GetToolVersionErrorInformation', "Error retrieving version information. See output channel '{0}' for more details", this.outputChannelName)); + this.setStatusDescription(localize('deployCluster.GetToolVersionError', "Error retrieving version information.{0}Invalid output received, get version command output: '{1}' ", EOL, commandOutput.stderr.join(EOL))); + this.setStatus(ToolStatus.NotInstalled); + } + } + protected getVersionFromOutput(output: string): SemVer | undefined { - let version: SemVer | undefined = undefined; - if (output && output.split(EOL).length > 0) { - version = new SemVerProxy(output.split(EOL)[0].replace(/ /g, '')); - } - return version; + return apiService.azdataApi.azdata.getSemVersion(); + } + protected async getSearchPaths(): Promise { switch (this.osDistribution) { case OsDistribution.win32: diff --git a/extensions/resource-deployment/src/services/tools/toolBase.ts b/extensions/resource-deployment/src/services/tools/toolBase.ts index 3f53a376b5..76080749ec 100644 --- a/extensions/resource-deployment/src/services/tools/toolBase.ts +++ b/extensions/resource-deployment/src/services/tools/toolBase.ts @@ -58,6 +58,8 @@ export abstract class ToolBase implements ITool { protected abstract readonly versionCommand: Command; + public validateEula(): boolean { return true; } + public get dependencyMessages(): string[] { return (this.dependenciesByOsType.get(this.osDistribution) || []).map((msgType: dependencyType) => messageByDependencyType.get(msgType)!); } @@ -126,10 +128,18 @@ export abstract class ToolBase implements ITool { return this._statusDescription; } + protected setStatusDescription(value: string | undefined): void { + this._statusDescription = value; + } + public get installationPathOrAdditionalInformation(): string | undefined { return this._installationPathOrAdditionalInformation; } + protected setInstallationPathOrAdditionalInformation(value: string | undefined) { + this._installationPathOrAdditionalInformation = value; + } + protected get installationCommands(): Command[] | undefined { return this.allInstallationCommands.get(this.osDistribution); } @@ -250,7 +260,7 @@ export abstract class ToolBase implements ITool { /** * updates the version and status for the tool. */ - private async updateVersionAndStatus(): Promise { + protected async updateVersionAndStatus(): Promise { this._statusDescription = ''; await this.addInstallationSearchPathsToSystemPath(); const commandOutput = await this.platformService.runCommand( @@ -306,7 +316,7 @@ export abstract class ToolBase implements ITool { } isSameOrNewerThan(version?: string): boolean { - return !version || (this._version ? SemVerCompare(this._version, version) >= 0 : false); + return !version || (this._version ? SemVerCompare(this._version.raw, version) >= 0 : false); } private _pendingVersionAndStatusUpdate!: Promise; diff --git a/extensions/resource-deployment/src/test/apiService.test.ts b/extensions/resource-deployment/src/test/apiService.test.ts index 3a2961a635..4b3595189c 100644 --- a/extensions/resource-deployment/src/test/apiService.test.ts +++ b/extensions/resource-deployment/src/test/apiService.test.ts @@ -8,9 +8,8 @@ import assert = require('assert'); 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/typings/ref.d.ts b/extensions/resource-deployment/src/typings/ref.d.ts index 8146e7bfb3..5f85967ab9 100644 --- a/extensions/resource-deployment/src/typings/ref.d.ts +++ b/extensions/resource-deployment/src/typings/ref.d.ts @@ -7,6 +7,7 @@ /// /// /// +/// /// /// /// 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..885a31b26e 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 { AzureAccountFieldInfo, AzureLocationsFieldInfo, ComponentCSSStyles, DialogInfoBase, FieldInfo, FieldType, FilePickerFieldInfo, KubeClusterContextFieldInfo, LabelPosition, NoteBookEnvironmentVariablePrefix, OptionsInfo, OptionsType, PageInfoBase, RowInfo, SectionInfo, TextCSSStyles } from '../interfaces'; +import { ArcControllersOptionsSource, OptionsSourceType } from '../helpers/optionSources'; +import { AzureAccountFieldInfo, AzureLocationsFieldInfo, ComponentCSSStyles, DialogInfoBase, FieldInfo, FieldType, FilePickerFieldInfo, IOptionsSource, 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,69 @@ 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 { + let optionsSource: IOptionsSource; + switch (context.fieldInfo.options.source.type) { + case OptionsSourceType.ArcControllersOptionsSource: + optionsSource = new ArcControllersOptionsSource(context.fieldInfo.options.source.variableNames, context.fieldInfo.options.source.type); + break; + default: + throw new Error(loc.noOptionsSourceDefined(context.fieldInfo.options.source.type)); + } + context.fieldInfo.options.source = optionsSource; + 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 +491,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 +644,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 +661,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 +1050,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 +1165,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 +1216,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 +1239,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 +1272,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 +1301,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 adf80e4d6b..a893239131 100644 --- a/extensions/resource-deployment/src/ui/resourceTypePickerDialog.ts +++ b/extensions/resource-deployment/src/ui/resourceTypePickerDialog.ts @@ -26,6 +26,7 @@ export class ResourceTypePickerDialog extends DialogBase { private _agreementContainer!: azdata.DivContainer; private _agreementCheckboxChecked: boolean = false; private _installToolButton: azdata.window.Button; + private _recheckEulaButton: azdata.window.Button; private _installationInProgress: boolean = false; private _tools: ITool[] = []; @@ -37,10 +38,15 @@ export class ResourceTypePickerDialog extends DialogBase { super(localize('resourceTypePickerDialog.title', "Select the deployment options"), 'ResourceTypePickerDialog', true); this._selectedResourceType = defaultResourceType; this._installToolButton = azdata.window.createButton(localize('deploymentDialog.InstallToolsButton', "Install tools")); + this._recheckEulaButton = azdata.window.createButton(localize('deploymentDialog.RecheckEulaButton', "Validate EULA")); this._toDispose.push(this._installToolButton.onClick(() => { this.installTools().catch(error => console.log(error)); })); - this._dialogObject.customButtons = [this._installToolButton]; + this._toDispose.push(this._recheckEulaButton.onClick(() => { + this._dialogObject.message = { text: '' }; // clear any previous message. + this._dialogObject.okButton.enabled = this.validateToolsEula(); // re-enable the okButton if validation succeeds. + })); + this._dialogObject.customButtons = [this._installToolButton, this._recheckEulaButton]; this._installToolButton.hidden = true; this._dialogObject.okButton.label = localize('deploymentDialog.OKButtonText', "Select"); this._dialogObject.okButton.enabled = false; // this is enabled after all tools are discovered. @@ -270,12 +276,12 @@ export class ResourceTypePickerDialog extends DialogBase { return [tool.displayName, tool.description, tool.displayStatus, tool.fullVersion || '', toolRequirement.version || '', tool.installationPathOrAdditionalInformation || '']; }); this._installToolButton.hidden = erroredOrFailedTool || minVersionCheckFailed || (toolsToAutoInstall.length === 0); - this._dialogObject.okButton.enabled = !erroredOrFailedTool && messages.length === 0 && !minVersionCheckFailed && (toolsToAutoInstall.length === 0); + this._dialogObject.okButton.enabled = !erroredOrFailedTool && messages.length === 0 && !minVersionCheckFailed && (toolsToAutoInstall.length === 0) && this.validateToolsEula(); if (messages.length !== 0) { if (messages.length > 1) { messages = messages.map(message => `• ${message}`); } - messages.push(localize('deploymentDialog.VersionInformationDebugHint', "You will need to restart Azure Data Studio if the tools are installed manually after Azure Data Studio is launched to pick up the updated PATH environment variable. You may find additional details in 'Deployments' output channel")); + messages.push(localize('deploymentDialog.VersionInformationDebugHint', "You will need to restart Azure Data Studio if the tools are installed manually to pick up the change. You may find additional details in 'Deployments' and 'azdata' output channels")); this._dialogObject.message = { level: azdata.window.MessageLevel.Error, text: messages.join(EOL) @@ -305,6 +311,21 @@ export class ResourceTypePickerDialog extends DialogBase { this._toolsLoadingComponent.loading = false; } + private validateToolsEula(): boolean { + const validationSucceeded = this._tools.every(tool => { + const eulaValidated = tool.validateEula(); + if (!eulaValidated) { + this._dialogObject.message = { + level: azdata.window.MessageLevel.Error, + text: tool.statusDescription! + }; + } + return eulaValidated; + }); + this._recheckEulaButton.hidden = validationSucceeded; + return validationSucceeded; + } + private get toolRequirements() { return this.getCurrentProvider().requiredTools; } @@ -347,7 +368,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/extensions/resource-deployment/yarn.lock b/extensions/resource-deployment/yarn.lock index 811f06eb35..4e6d54ff71 100644 --- a/extensions/resource-deployment/yarn.lock +++ b/extensions/resource-deployment/yarn.lock @@ -194,6 +194,11 @@ resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-5.2.7.tgz#315d570ccb56c53452ff8638738df60726d5b6ea" integrity sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ== +"@types/semver@^7.3.1": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.4.tgz#43d7168fec6fa0988bb1a513a697b29296721afb" + integrity sha512-+nVsLKlcUCeMzD2ufHEYuJ9a2ovstb6Dp52A5VsoKxDXgvE051XgHI/33I1EymwkRGQkwnA0LkhnUzituGs4EQ== + "@types/yamljs@0.2.30": version "0.2.30" resolved "https://registry.yarnpkg.com/@types/yamljs/-/yamljs-0.2.30.tgz#d034e1d329e46e8d0f737c9a8db97f68f81b5382" @@ -694,6 +699,11 @@ semver@^6.0.0, semver@^6.3.0: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== +semver@^7.3.2: + version "7.3.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.2.tgz#604962b052b81ed0786aae84389ffba70ffd3938" + integrity sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ== + should-equal@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/should-equal/-/should-equal-2.0.0.tgz#6072cf83047360867e68e98b09d71143d04ee0c3" 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",