From ebe265ccb1ffe93fb1a3e24ea2d8f452eac319ce Mon Sep 17 00:00:00 2001 From: Bhavikshah123 <126583651+Bhavikshah123@users.noreply.github.com> Date: Tue, 17 Sep 2024 14:32:12 +0530 Subject: [PATCH] Login Migration Pre Validation Changes (#25897) * Login Migration Pre Validation Changes * Minor string changes --- .../sql-migration/src/constants/strings.ts | 123 +++- .../loginPreMigrationValidationDialog.ts | 610 ++++++++++++++++++ .../sql-migration/src/models/stateMachine.ts | 22 + extensions/sql-migration/src/telemetry.ts | 1 + .../src/wizard/loginSelectorPage.ts | 76 ++- .../src/wizard/wizardController.ts | 8 +- 6 files changed, 828 insertions(+), 12 deletions(-) create mode 100644 extensions/sql-migration/src/dialog/loginMigration/loginPreMigrationValidationDialog.ts diff --git a/extensions/sql-migration/src/constants/strings.ts b/extensions/sql-migration/src/constants/strings.ts index 2ba7fe01f5e..24cc0734382 100644 --- a/extensions/sql-migration/src/constants/strings.ts +++ b/extensions/sql-migration/src/constants/strings.ts @@ -480,8 +480,9 @@ export function LOGIN_MIGRATIONS_GET_LOGINS_ERROR_TITLE(targetType: string): str export function LOGIN_MIGRATIONS_GET_LOGINS_ERROR(message: string): string { return localize('sql.migration.wizard.target.login.error', "Error getting login information: {0}", message); } -export const SELECT_LOGIN_TO_CONTINUE = localize('sql.migration.select.database.to.continue', "Please select 1 or more logins for migration"); -export const ENTER_AAD_DOMAIN_NAME = localize('sql.login.migration.enter.AAD.domain.name.to.continue', "Microsoft Entra ID Domain name is required to migrate Windows login. Please enter an AAD Domain Name or deselect windows login(s)."); +export const SELECT_LOGIN_TO_CONTINUE = localize('sql.migration.select.login.to.continue', "Select one or more logins to run validation."); +export const SELECT_LOGIN_AND_RUN_VALIDATION_TO_CONTINUE = localize('sql.migration.select.login.run.validation.to.continue', "To perform login migration, please run validation for one or more logins."); +export const ENTER_ENTRA_ID = localize('sql.login.migration.enter.entra.id.to.continue', "Microsoft Entra Domain is required to migrate Windows login. Please enter Microsoft Entra Domain name or deselect windows login(s)"); export const LOGIN_MIGRATE_BUTTON_TEXT = localize('sql.migration.start.login.migration.button', "Migrate"); export function LOGIN_MIGRATIONS_GET_CONNECTION_STRING(dataSource: string, id: string, pass: string): string { return localize('sql.login.migration.get.connection.string', "data source={0};initial catalog=master;user id={1};password={2};TrustServerCertificate=True;Integrated Security=false;", dataSource, id, pass); @@ -509,8 +510,8 @@ export const LOGINS_NOT_FOUND = localize('sql.login.migration.logins.not.found', export const LOGIN_MIGRATION_STATUS_SUCCEEDED = localize('sql.login.migration.status.succeeded', "Succeeded"); export const LOGIN_MIGRATION_STATUS_FAILED = localize('sql.login.migration.status.failed', "Failed"); export const LOGIN_MIGRATION_STATUS_IN_PROGRESS = localize('sql.login.migration.status.in.progress', "In progress"); -export const LOGIN_MIGRATIONS_AAD_DOMAIN_NAME_INPUT_BOX_LABEL = localize('sql.login.migration.aad.domain.name.input.box.label', "Microsoft Entra ID Domain Name (only required to migrate Windows Authenication Logins)"); -export const LOGIN_MIGRATIONS_AAD_DOMAIN_NAME_INPUT_BOX_PLACEHOLDER = localize('sql.login.migration.aad.domain.name.input.box.placeholder', "Enter AAD Domain Name"); +export const LOGIN_MIGRATIONS_ENTRA_ID_INPUT_BOX_LABEL = localize('sql.login.migration.entra.id.input.box.label', "Microsoft Entra Domain Name (only required to migrate Windows Authentication Logins)"); +export const LOGIN_MIGRATIONS_ENTRA_ID_INPUT_BOX_PLACEHOLDER = localize('sql.login.migration.entra.id.input.box.placeholder', "Enter Microsoft Entra Domain name"); export function LOGIN_MIGRATIONS_LOGIN_STATUS_DETAILS_TITLE(loginName: string): string { return localize('sql.login.migration.login.status.details.title', "Migration status details for {0}", loginName); } @@ -823,6 +824,7 @@ export const TABLE_SELECTION_HASROWS_COLUMN = localize('sql.migration.table.sele export const VALIDATION_DIALOG_TITLE = localize('sql.migration.validation.dialog.title', "Running validation"); export const VALIDATION_MESSAGE_SUCCESS = localize('sql.migration.validation.success', "Validation completed successfully. Please click Next to proceed with the migration."); +export const LOGIN_MIGRATION_VALIDATION_MESSAGE_SUCCESS = localize('login.migration.validation.success', "Validation completed successfully. Please click migrate to proceed with the migration."); export function VALIDATION_MESSAGE_CANCELED_ERRORS(msg: string): string { return localize( 'sql.migration.validation.canceled.errors', @@ -1016,6 +1018,12 @@ export const VALIDATE_IR_COLUMN_VALIDATION_STEPS = localize('sql.migration.valid export const VALIDATE_IR_COLUMN_STATUS = localize('sql.migration.validate.ir.column.status', "Status"); export const VALIDATE_IR_VALIDATION_RESULT_LABEL_SHIR = localize('sql.migration.validate.ir.validation.result.label.shir', "Integration runtime connectivity"); export const VALIDATE_IR_VALIDATION_RESULT_LABEL_STORAGE = localize('sql.migration.validate.ir.validation.result.label.storage', "Azure storage connectivity"); +export const VALIDATE_LOGIN_MIGRATION_VALIDATION_RESULT_LABEL_SYSADMIN = localize('sql.migration.validate.login.migration.validation.result.label.sysadmin', + "Validating the sysadmin permission on source and target"); +export const VALIDATE_LOGIN_MIGRATION_VALIDATION_RESULT_LABEL_ENTRAID = localize('sql.migration.validate.login.migration.validation.result.label.entraid', + "Validating the microsoft entra id"); +export const VALIDATE_LOGIN_MIGRATION_VALIDATION_RESULT_LABEL_USERMAPPING = localize('sql.migration.validate.login.migration.validation.result.label.user.mapping', + "Validating the user mapping"); export function VALIDATE_IR_VALIDATION_RESULT_LABEL_SOURCE_DATABASE(databaseName: string): string { return localize( @@ -1079,6 +1087,113 @@ export function VALIDATION_IR_BUTTON_MISSING_ERROR_MESSAGE(details: string[]): s missingDetails); } +// Validate Login migration validation dialog +export const VALIDATE_LOGIN_MIGRATION_DONE_BUTTON = localize('sql.migration.validate.login.migration.done.button', "Done"); +export const VALIDATE_LOGIN_MIGRATION_HEADING = localize('sql.migration.validate.login.migration.heading', "We are validating the following:"); +export const VALIDATE_LOGIN_MIGRATION_START_VALIDATION = localize('sql.migration.validate.login.migration.start.validation', "Start validation"); +export const VALIDATE_LOGIN_MIGRATION_UNSUCCESSFUL_REVALIDATION = localize('sql.migration.validate.login.migration.unsuccessful.revalidation', "Revalidate unsuccessful steps"); +export const VALIDATE_LOGIN_MIGRATION_STOP_VALIDATION = localize('sql.migration.validate.login.migration.stop.validation', "Stop validation"); +export const VALIDATE_LOGIN_MIGRATION_COPY_RESULTS = localize('sql.migration.validate.login.migration.copy.results', "Copy validation results"); +export const VALIDATE_LOGIN_MIGRATION_RESULTS_HEADING = localize('sql.migration.validate.login.migration.results.heading', "Validation step details"); +export const VALIDATE_LOGIN_MIGRATION_VALIDATION_COMPLETED = localize('sql.migration.validate.login.migration.validation.completed', "Validation completed successfully."); +export const VALIDATE_LOGIN_MIGRATION_VALIDATION_CANCELED = localize('sql.migration.validate.login.migration.validation.camceled', "Validation check canceled"); + +export function VALIDATE_LOGIN_MIGRATION_VALIDATION_COMPLETED_ERRORS(msg: string): string { + return localize( + 'sql.migration.validate.login.migration.completed.errors', + "Validation completed with the following error(s):{0}{1}", EOL, msg); +} +export function VALIDATE_LOGIN_MIGRATION_VALIDATION_STATUS(state: string | undefined, errors?: string[]): string { + const status = state ?? ''; + if (errors && errors.length > 0) { + return localize( + 'sql.migration.validate.login.migration.status.errors', + "Validation status: {0}{1}{2}", status, EOL, errors.join(EOL)); + } else { + return localize( + 'sql.migration.validate.login.migration.status', + "Validation status: {0}", status); + } +} + +export const VALIDATE_LOGIN_MIGRATION_ERROR_GATEWAY_TIMEOUT = localize('sql.migration.validate.error.gatewaytimeout', "A time-out was encountered while validating a resource connection. Learn more: https://aka.ms/dms-migrations-troubleshooting."); + +export function VALIDATE_LOGIN_MIGRATION_VALIDATION_STATUS_ERROR_COUNT(state: string | undefined, errorCount: number): string { + const status = state ?? ''; + return errorCount > 1 + ? localize( + 'sql.migration.validate.login.migration.status.error.count.many', + "{0} - {1} errors", + status, + errorCount) + : localize( + 'sql.migration.validate.login.migration.status.error.count.one', + "{0} - 1 error", + status); +} + +export function VALIDATE_LOGIN_MIGRATION_VALIDATION_STATUS_ERROR(state: string | undefined, errors: string[]): string { + const status = state ?? ''; + return localize( + 'sql.migration.validate.login.migration.status.error', + "{0}{1}{2}", + status, + EOL, + errors.join(EOL)); +} + +export function VALIDATE_LOGIN_MIGRATION_SYSADMIN_PERMISSION_VALIDATION_RESULT_ERROR(serverName: string, error: any,): string { + return localize( + 'sql.migration.validate.ir.sqldb.validation.result.error', + "Sys Admin Permission Pre Validation check error{0}server: {1}{0}Error: {2} - {3}{0}Follow the steps mentioned here https://aka.ms/loginvalidationerror and re-run the validation before performing login/s migration.", + EOL, + serverName, + error.ErrorCodeString, + error.Message); +} + +export function VALIDATE_LOGIN_MIGRATION_ENTRA_ID_VALIDATION_RESULT_ERROR(entraID: string, error: any,): string { + return localize( + 'sql.migration.validate.ir.sqldb.validation.result.error', + "Entra ID Pre Validation check error{0}Entra ID: {1}{0}Error: {2} - {3}{0}Follow the steps mentioned here https://aka.ms/loginvalidationerror and re-run the validation or uncheck the failed logins before performing login/s migration.", + EOL, + entraID, + error.ErrorCodeString, + error.Message); +} + +export function VALIDATE_LOGIN_MIGRATION_USER_MAPPING_VALIDATION_RESULT_ERROR(name: string, error: any,): string { + return localize( + 'sql.migration.validate.ir.sqldb.validation.result.error', + "User Mapping Pre Validation check error{0}Name: {1}{0}Error: {2} - {3}{0}Follow the steps mentioned here https://aka.ms/loginvalidationerror and re-run the validation or uncheck the failed logins before performing login/s migration.", + EOL, + name, + error.ErrorCodeString, + error.Message); +} + +export function GET_LOGIN_MIGRATION_VALIDATION_ERROR(validationFunctionName: any, name: string, error: any): any { + switch (validationFunctionName) { + case 'validateUserMapping': + return VALIDATE_LOGIN_MIGRATION_USER_MAPPING_VALIDATION_RESULT_ERROR(name, error); + case 'validateAADDomainName': + return VALIDATE_LOGIN_MIGRATION_ENTRA_ID_VALIDATION_RESULT_ERROR(name, error) + case 'validateSysAdminPermission': + return VALIDATE_LOGIN_MIGRATION_SYSADMIN_PERMISSION_VALIDATION_RESULT_ERROR(name, error) + default: + return ''; + } +} + +export function VALIDATE_LOGIN_MIGRATION_VALIDATION_RESULT_API_ERROR(error: Error): string { + return localize( + 'sql.migration.validate.login.migration.validation.result.api.error', + "Validation check error{0}Error: {1} - {2}", + EOL, + error.name, + error.message); +} + // common strings export const WARNING = localize('sql.migration.warning', "Warning"); export const ERROR = localize('sql.migration.error', "Error"); diff --git a/extensions/sql-migration/src/dialog/loginMigration/loginPreMigrationValidationDialog.ts b/extensions/sql-migration/src/dialog/loginMigration/loginPreMigrationValidationDialog.ts new file mode 100644 index 00000000000..c713cd900d8 --- /dev/null +++ b/extensions/sql-migration/src/dialog/loginMigration/loginPreMigrationValidationDialog.ts @@ -0,0 +1,610 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import * as constants from '../../constants/strings'; +import { IconPathHelper } from '../../constants/iconPathHelper'; +import { LoginMigrationValidationResult, MigrationStateModel, ValidateLoginMigrationValidationState } from '../../models/stateMachine'; +import { getSourceConnectionString, getTargetConnectionString } from '../../api/sqlUtils'; +import { logError, TelemetryViews } from '../../telemetry'; +import { EOL } from 'os'; + +const DialogName = 'LoginPreMigrationValidationDialog'; + +enum HttpStatusCodes { + GatewayTimeout = "504\r\n", +} + +enum HttpStatusExceptionCodes { + ConnectionTimeoutError = "ConnectionTimeoutError", +} + +enum ValidationResultIndex { + message = 0, + icon = 1, + status = 2, + errors = 3, + state = 4, +} + +export const ValidationStatusLookup: constants.LookupTable = { + [ValidateLoginMigrationValidationState.Canceled]: constants.VALIDATION_STATE_CANCELED, + [ValidateLoginMigrationValidationState.Failed]: constants.VALIDATION_STATE_FAILED, + [ValidateLoginMigrationValidationState.Pending]: constants.VALIDATION_STATE_PENDING, + [ValidateLoginMigrationValidationState.Running]: constants.VALIDATION_STATE_RUNNING, + [ValidateLoginMigrationValidationState.Succeeded]: constants.VALIDATION_STATE_SUCCEEDED, + default: undefined +}; + +export class LoginPreMigrationValidationDialog { + private _canceled: boolean = true; + private _dialog: azdata.window.Dialog | undefined; + private _isOpen: boolean = false; + private _model!: MigrationStateModel; + private _resultsTable!: azdata.TableComponent; + private _startLoader!: azdata.LoadingComponent; + private _startButton!: azdata.ButtonComponent; + private _revalidationButton!: azdata.ButtonComponent; + private _cancelButton!: azdata.ButtonComponent; + private _copyButton!: azdata.ButtonComponent; + private _validationResult: any[][] = []; + private _disposables: vscode.Disposable[] = []; + private _valdiationErrors: string[] = []; + private _onClosed: () => void; + + constructor( + model: MigrationStateModel, + onClosed: () => void) { + this._model = model; + this._onClosed = onClosed; + } + + public async openDialog(dialogTitle: string, results?: LoginMigrationValidationResult[]): Promise { + if (!this._isOpen) { + this._isOpen = true; + this._dialog = azdata.window.createModelViewDialog( + dialogTitle, + DialogName, + 600); + + const promise = this._initializeDialog(this._dialog); + azdata.window.openDialog(this._dialog); + await promise; + + return this._runValidation(results); + } + } + + private async _initializeDialog(dialog: azdata.window.Dialog): Promise { + return new Promise((resolve, reject) => { + dialog.registerContent(async (view) => { + try { + dialog.okButton.label = constants.VALIDATE_LOGIN_MIGRATION_DONE_BUTTON; + dialog.okButton.position = 'left'; + dialog.okButton.enabled = false; + dialog.cancelButton.position = 'left'; + + this._disposables.push( + dialog.cancelButton.onClick( + e => { + this._canceled = true; + this._saveResults(); + this._onClosed(); + })); + + this._disposables.push( + dialog.okButton.onClick( + e => this._onClosed())); + + const headingText = view.modelBuilder.text() + .withProps({ + value: constants.VALIDATE_LOGIN_MIGRATION_HEADING, + CSSStyles: { + 'font-size': '13px', + 'font-weight': '400', + 'margin-bottom': '10px', + }, + }) + .component(); + this._startLoader = view.modelBuilder.loadingComponent() + .withProps({ + loading: false, + CSSStyles: { 'margin': '5px 0 0 10px' } + }) + .component(); + const headingContainer = view.modelBuilder.flexContainer() + .withLayout({ + flexFlow: 'row', + justifyContent: 'flex-start', + }) + .withItems([headingText, this._startLoader], { flex: '0 0 auto' }) + .component(); + + this._resultsTable = await this._createResultsTable(view); + + this._startButton = view.modelBuilder.button() + .withProps({ + iconPath: IconPathHelper.restartDataCollection, + iconHeight: 18, + iconWidth: 18, + width: 100, + label: constants.VALIDATE_LOGIN_MIGRATION_START_VALIDATION, + }).component(); + + this._cancelButton = view.modelBuilder.button() + .withProps({ + iconPath: IconPathHelper.stop, + iconHeight: 18, + iconWidth: 18, + width: 100, + label: constants.VALIDATE_LOGIN_MIGRATION_STOP_VALIDATION, + enabled: false, + }).component(); + + this._revalidationButton = view.modelBuilder.button() + .withProps({ + iconPath: IconPathHelper.redo, + iconHeight: 18, + iconWidth: 18, + width: 170, + label: constants.VALIDATE_LOGIN_MIGRATION_UNSUCCESSFUL_REVALIDATION, + enabled: false, + }).component(); + + this._copyButton = view.modelBuilder.button() + .withProps({ + iconPath: IconPathHelper.copy, + iconHeight: 18, + iconWidth: 18, + width: 140, + label: constants.VALIDATE_LOGIN_MIGRATION_COPY_RESULTS, + enabled: false, + }).component(); + + this._disposables.push( + this._startButton.onDidClick( + async (e) => await this._runValidation())); + + this._disposables.push( + this._revalidationButton.onDidClick( + async (e) => await this._runUnsuccessfulRevalidation())); + this._disposables.push( + this._cancelButton.onDidClick( + e => { + this._cancelButton.enabled = false; + this._canceled = true; + })); + + this._disposables.push( + this._copyButton.onDidClick( + async (e) => this._copyValidationResults())); + + const toolbar = view.modelBuilder.toolbarContainer() + .withToolbarItems([ + { component: this._startButton }, + { component: this._cancelButton }, + { component: this._revalidationButton }, + { component: this._copyButton }]) + .component(); + + const resultsHeading = view.modelBuilder.text() + .withProps({ + value: constants.VALIDATE_LOGIN_MIGRATION_RESULTS_HEADING, + CSSStyles: { + 'font-size': '16px', + 'font-weight': '600', + 'margin-bottom': '10px' + }, + }) + .component(); + const resultsText = view.modelBuilder.inputBox() + .withProps({ + inputType: 'text', + height: 200, + multiline: true, + CSSStyles: { 'overflow': 'none auto' } + }) + .component(); + + this._disposables.push( + this._resultsTable.onRowSelected( + async (e) => await this._updateResultsInfoBox(resultsText))); + + const flex = view.modelBuilder.flexContainer() + .withItems([ + headingContainer, + toolbar, + this._resultsTable, + resultsHeading, + resultsText], + { flex: '0 0 auto' }) + .withProps({ CSSStyles: { 'margin': '0 0 0 15px' } }) + .withLayout({ + flexFlow: 'column', + height: '100%', + width: 565, + }).component(); + + this._disposables.push( + view.onClosed(e => + this._disposables.forEach( + d => { try { d.dispose(); } catch { } }))); + + await view.initializeModel(flex); + resolve(); + } catch (ex) { + reject(ex); + } + }); + }); + } + + private _copyValidationResults(): any { + const errorsText = this._valdiationErrors.join(EOL); + const msg = errorsText.length === 0 + ? constants.VALIDATE_LOGIN_MIGRATION_VALIDATION_COMPLETED + : constants.VALIDATE_LOGIN_MIGRATION_VALIDATION_COMPLETED_ERRORS(errorsText); + return vscode.env.clipboard.writeText(msg); + } + + private async _runUnsuccessfulRevalidation(): Promise { + try { + this._startLoader.loading = true; + this._startButton.enabled = false; + this._revalidationButton.enabled = false; + this._cancelButton.enabled = true; + this._copyButton.enabled = false; + this._dialog!.okButton.enabled = false; + this._dialog!.cancelButton.enabled = true; + if (!this._model.isLoginMigrationTargetValidated) { + await this._revalidate(); + } + } finally { + this._startLoader.loading = false; + this._startButton.enabled = true; + this._revalidationButton.enabled = !this._model.isLoginMigrationTargetValidated; + this._cancelButton.enabled = false; + this._copyButton.enabled = true; + this._dialog!.okButton.enabled = this._model.isLoginMigrationTargetValidated; + this._dialog!.cancelButton.enabled = !this._model.isLoginMigrationTargetValidated; + } + } + + private async _updateResultsInfoBox(text: azdata.InputBoxComponent): Promise { + const selectedRows: number[] = this._resultsTable.selectedRows ?? []; + const statusMessages: string[] = []; + if (selectedRows.length > 0) { + for (let i = 0; i < selectedRows.length; i++) { + const row = selectedRows[i]; + const results: any[] = this._validationResult[row]; + const status = results[ValidationResultIndex.status]; + const errors = results[ValidationResultIndex.errors]; + statusMessages.push( + constants.VALIDATE_LOGIN_MIGRATION_VALIDATION_STATUS(ValidationStatusLookup[status], errors)); + } + } + + const msg = statusMessages.length > 0 + ? statusMessages.join(EOL) + : ''; + text.value = msg; + } + + private async _createResultsTable(view: azdata.ModelView): Promise { + return view.modelBuilder.table() + .withProps({ + columns: [ + { + value: 'test', + name: "Validation steps", + type: azdata.ColumnType.text, + width: 380, + headerCssClass: 'no-borders', + cssClass: 'no-borders align-with-header', + }, + { + value: 'image', + name: '', + type: azdata.ColumnType.icon, + width: 20, + headerCssClass: 'no-borders display-none', + cssClass: 'no-borders align-with-header', + }, + { + value: 'message', + name: "Status", + type: azdata.ColumnType.text, + width: 150, + headerCssClass: 'no-borders', + cssClass: 'no-borders align-with-header', + }, + ], + data: [], + width: 580, + height: 300, + CSSStyles: { + 'margin-top': '10px', + 'margin-bottom': '10px', + }, + }) + .component(); + } + + private _saveResults(): void { + const results = this._validationResults(); + this._model._validateLoginMigration = results; + } + + private _validationResults(): LoginMigrationValidationResult[] { + return this._validationResult.map(result => { + const state = result[ValidationResultIndex.state]; + const finalState = this._canceled + ? (state === ValidateLoginMigrationValidationState.Running || state === ValidateLoginMigrationValidationState.Pending) + ? ValidateLoginMigrationValidationState.Canceled + : state + : state; + const errors = result[ValidationResultIndex.errors] ?? []; + return { + errors: errors, + state: finalState, + }; + }); + } + + private async _runValidation(results?: LoginMigrationValidationResult[]): Promise { + try { + this._startLoader.loading = true; + this._startButton.enabled = false; + this._revalidationButton.enabled = false; + this._cancelButton.enabled = true; + this._copyButton.enabled = false; + this._dialog!.okButton.enabled = false; + this._dialog!.cancelButton.enabled = true; + await this._validate(); + } finally { + this._startLoader.loading = false; + this._startButton.enabled = true; + this._revalidationButton.enabled = !this._model.isLoginMigrationTargetValidated; + this._cancelButton.enabled = false; + this._copyButton.enabled = true; + this._dialog!.okButton.enabled = this._model.isLoginMigrationTargetValidated; + this._dialog!.cancelButton.enabled = !this._model.isLoginMigrationTargetValidated; + } + } + + private async _validate(): Promise { + this._canceled = false; + await this._initializeResults(); + await this._validateLoginMigration(); + this._saveResults(); + } + + private async _revalidate(): Promise { + await this._initLoginMigrationResultsForRevalidation(); + await this._validateLoginMigration(true); + this._saveResults(); + } + + private _formatError(error: Error): Error { + if (error?.message?.startsWith(HttpStatusCodes.GatewayTimeout)) { + return { + name: HttpStatusExceptionCodes.ConnectionTimeoutError, + message: constants.VALIDATE_LOGIN_MIGRATION_ERROR_GATEWAY_TIMEOUT, + }; + } + return error; + } + + private async _validateLoginMigration(skipSuccessfulSteps: boolean = false): Promise { + let testNumber: number = 0; + + const validate = async ( + validationFunction: any, + loginList: string[], + aadDomainName: string, + ): Promise => { + + try { + await this._updateValidateLoginMigrationResults(testNumber, ValidateLoginMigrationValidationState.Running); + const sourceConnectionString: string = await getSourceConnectionString(); + const targetConnectionString: string = await getTargetConnectionString( + this._model.targetServerName, + this._model._targetServerInstance.id, + this._model._targetUserName, + this._model._targetPassword, + this._model._targetPort, + // for login migration, connect to target Azure SQL with true/true + // to-do: take as input from the user, should be true/false for DB/MI but true/true for VM + true /* encryptConnection */, + true /* trustServerCertificate */); + const validationFunctionName = validationFunction.name.replace('bound ', ''); + + var validationResult = await validationFunction( + sourceConnectionString, + targetConnectionString, + loginList, + aadDomainName); + + if (validationResult !== undefined) { + if (Object.keys(validationResult.exceptionMap).length > 0) { + var errors: any[] = [] + Object.keys(validationResult.exceptionMap).forEach(name => { + const error = validationResult?.exceptionMap[name][0] + errors.push(constants.GET_LOGIN_MIGRATION_VALIDATION_ERROR( + validationFunctionName, + name, + error)) + }); + await this._updateValidateLoginMigrationResults(testNumber, ValidateLoginMigrationValidationState.Failed, errors); + } else { + await this._updateValidateLoginMigrationResults(testNumber, ValidateLoginMigrationValidationState.Succeeded); + return true; + } + } + } catch (error) { + const err = this._formatError(error); + logError(TelemetryViews.LoginMigrationPreValdationDialog, err.message, error); + await this._updateValidateLoginMigrationResults( + testNumber, + ValidateLoginMigrationValidationState.Failed, + [constants.VALIDATE_LOGIN_MIGRATION_VALIDATION_RESULT_API_ERROR(err)]); + } + return false; + }; + + // validate sys admin permissions + if (!skipSuccessfulSteps || this._validationResult[testNumber][ValidationResultIndex.state] !== ValidateLoginMigrationValidationState.Succeeded) { + var loginList: string[] = []; + var aadDomainName = ""; + if (!await validate(this._model.migrationService.validateSysAdminPermission.bind(this._model.migrationService), loginList, aadDomainName)) { + this._canceled = true; + await this._updateValidateLoginMigrationResults(testNumber + 1, ValidateLoginMigrationValidationState.Canceled, [constants.VALIDATE_LOGIN_MIGRATION_VALIDATION_CANCELED]); + return; + } + } + + testNumber++; + if (this._canceled) { + await this._updateValidateLoginMigrationResults(testNumber, ValidateLoginMigrationValidationState.Canceled, [constants.VALIDATE_LOGIN_MIGRATION_VALIDATION_CANCELED]); + } + // validate Entra ID + else if (!skipSuccessfulSteps || this._validationResult[testNumber][ValidationResultIndex.state] !== ValidateLoginMigrationValidationState.Succeeded) { + var loginList: string[] = this._model._loginMigrationModel.loginsForMigration.map(row => row.loginName); + var aadDomainName = this._model._aadDomainName; + if (!await validate(this._model.migrationService.validateAADDomainName.bind(this._model.migrationService), loginList, aadDomainName)) { + this._canceled = true; + await this._updateValidateLoginMigrationResults(testNumber + 1, ValidateLoginMigrationValidationState.Canceled, [constants.VALIDATE_LOGIN_MIGRATION_VALIDATION_CANCELED]); + return; + } + } + + testNumber++; + if (this._canceled) { + await this._updateValidateLoginMigrationResults(testNumber, ValidateLoginMigrationValidationState.Canceled, [constants.VALIDATE_LOGIN_MIGRATION_VALIDATION_CANCELED]); + } + // validate user mapping + else if (!skipSuccessfulSteps || this._validationResult[testNumber][ValidationResultIndex.state] !== ValidateLoginMigrationValidationState.Succeeded) { + var loginList: string[] = this._model._loginMigrationModel.loginsForMigration.map(row => row.loginName); + var aadDomainName = this._model._aadDomainName; + if (!await validate(this._model.migrationService.validateUserMapping.bind(this._model.migrationService), loginList, aadDomainName)) { + this._canceled = true; + await this._updateValidateLoginMigrationResults(testNumber + 1, ValidateLoginMigrationValidationState.Canceled, [constants.VALIDATE_LOGIN_MIGRATION_VALIDATION_CANCELED]); + return; + } + } + } + + private async _updateValidateLoginMigrationResults(row: number, state: ValidateLoginMigrationValidationState, errors: string[] = [], updateTable: boolean = true): Promise { + if (state === ValidateLoginMigrationValidationState.Canceled) { + for (let cancelRow = row; cancelRow < this._validationResult.length; cancelRow++) { + await this._updateResults(cancelRow, state, errors); + } + } else { + await this._updateResults(row, state, errors); + } + + if (updateTable) { + const data = this._validationResult.map(row => [ + row[ValidationResultIndex.message], + row[ValidationResultIndex.icon], + row[ValidationResultIndex.status]]); + await this._resultsTable.updateProperty('data', data); + } + + this._valdiationErrors.push(...errors); + } + + private async _updateResults(row: number, state: ValidateLoginMigrationValidationState, errors: string[] = []): Promise { + const result = this._validationResult[row]; + const status = ValidationStatusLookup[state]; + const statusMsg = state === ValidateLoginMigrationValidationState.Failed && errors.length > 0 + ? constants.VALIDATE_LOGIN_MIGRATION_VALIDATION_STATUS_ERROR_COUNT(status, errors.length) + : status; + + const statusMessage = errors.length > 0 + ? constants.VALIDATE_LOGIN_MIGRATION_VALIDATION_STATUS_ERROR(status, errors) + : statusMsg; + + this._validationResult[row] = [ + result[ValidationResultIndex.message], + { + icon: this._getValidationStateImage(state), + title: statusMessage, + }, + statusMsg, + errors, + state]; + } + + private _getValidationStateImage(state: ValidateLoginMigrationValidationState): azdata.IconPath { + switch (state) { + case ValidateLoginMigrationValidationState.Canceled: + return IconPathHelper.cancel; + case ValidateLoginMigrationValidationState.Failed: + return IconPathHelper.error; + case ValidateLoginMigrationValidationState.Running: + return IconPathHelper.inProgressMigration; + case ValidateLoginMigrationValidationState.Succeeded: + return IconPathHelper.completedMigration; + case ValidateLoginMigrationValidationState.Pending: + default: + return IconPathHelper.notStartedMigration; + } + } + + private async _initializeResults(results?: LoginMigrationValidationResult[]): Promise { + this._valdiationErrors = []; + this._validationResult = []; + + this._addValidationResult(constants.VALIDATE_LOGIN_MIGRATION_VALIDATION_RESULT_LABEL_SYSADMIN); + this._addValidationResult(constants.VALIDATE_LOGIN_MIGRATION_VALIDATION_RESULT_LABEL_ENTRAID); + this._addValidationResult(constants.VALIDATE_LOGIN_MIGRATION_VALIDATION_RESULT_LABEL_USERMAPPING); + + if (results && results.length > 0) { + for (let row = 0; row < results.length; row++) { + await this._updateValidateLoginMigrationResults( + row, + results[row].state, + results[row].errors, + false); + } + } + + const data = this._validationResult.map(row => [ + row[ValidationResultIndex.message], + row[ValidationResultIndex.icon], + "Pending"]); + + await this._resultsTable.updateProperty('data', data); + } + + private async _initLoginMigrationResultsForRevalidation(): Promise { + this._valdiationErrors = []; + this._canceled = false; + let testNumber: number = 0; + + this._validationResult.forEach(async element => { + if (element[ValidationResultIndex.state] !== ValidateLoginMigrationValidationState.Succeeded) { + await this._updateValidateLoginMigrationResults(testNumber++, ValidateLoginMigrationValidationState.Pending); + } + else { + testNumber++; + } + }); + } + + private _addValidationResult(message: string): void { + this._validationResult.push([ + message, + { + icon: IconPathHelper.notStartedMigration, + title: ValidationStatusLookup[ValidateLoginMigrationValidationState.Pending], + }, + ValidationStatusLookup[ValidateLoginMigrationValidationState.Pending], + [], + ValidateLoginMigrationValidationState.Pending]); + } +} diff --git a/extensions/sql-migration/src/models/stateMachine.ts b/extensions/sql-migration/src/models/stateMachine.ts index 227b3af9f1c..2f12c3227b9 100644 --- a/extensions/sql-migration/src/models/stateMachine.ts +++ b/extensions/sql-migration/src/models/stateMachine.ts @@ -29,11 +29,24 @@ export enum ValidateIrState { Canceled = 'Canceled', } +export enum ValidateLoginMigrationValidationState { + Pending = 'Pending', + Running = 'Running', + Succeeded = 'Succeeded', + Failed = 'Failed', + Canceled = 'Canceled', +} + export interface ValidationResult { errors: string[]; state: ValidateIrState; } +export interface LoginMigrationValidationResult { + errors: string[]; + state: ValidateLoginMigrationValidationState; +} + export enum State { INIT, COLLECTING_SOURCE_INFO, @@ -238,6 +251,7 @@ export class MigrationStateModel implements Model, vscode.Disposable { public _validateIrSqlDb: ValidationResult[] = []; public _validateIrSqlMi: ValidationResult[] = []; public _validateIrSqlVm: ValidationResult[] = []; + public _validateLoginMigration: LoginMigrationValidationResult[] = []; public _skuRecommendationResults!: SkuRecommendation; public _skuRecommendationPerformanceDataSource!: PerformanceDataSourceOptions; @@ -358,6 +372,14 @@ export class MigrationStateModel implements Model, vscode.Disposable { r.state === ValidateIrState.Succeeded) } + public get isLoginMigrationTargetValidated(): boolean { + const results = this._validateLoginMigration ?? []; + return results.length > 1 + && results.every(r => + r.errors.length === 0 && + r.state === ValidateLoginMigrationValidationState.Succeeded) + } + public get migrationTargetServerName(): string { switch (this._targetType) { case MigrationTargetType.SQLMI: diff --git a/extensions/sql-migration/src/telemetry.ts b/extensions/sql-migration/src/telemetry.ts index 343941a0800..85a7ef1fdc6 100644 --- a/extensions/sql-migration/src/telemetry.ts +++ b/extensions/sql-migration/src/telemetry.ts @@ -43,6 +43,7 @@ export enum TelemetryViews { LoginMigrationTargetSelectionPage = 'LoginMigrationTargetSelectionPage', LoginMigrationSelectorPage = 'LoginMigrationSelectorPage', LoginMigrationStatusPage = 'LoginMigrationStatusPage', + LoginMigrationPreValdationDialog = 'LoginMigrationPreValdationDialog', TdeConfigurationDialog = 'TdeConfigurationDialog', TdeMigrationDialog = 'TdeMigrationDialog', ValidIrDialog = 'validIrDialog', diff --git a/extensions/sql-migration/src/wizard/loginSelectorPage.ts b/extensions/sql-migration/src/wizard/loginSelectorPage.ts index 3a6c10d9795..3f82453e7a2 100644 --- a/extensions/sql-migration/src/wizard/loginSelectorPage.ts +++ b/extensions/sql-migration/src/wizard/loginSelectorPage.ts @@ -6,7 +6,7 @@ import * as azdata from 'azdata'; import * as vscode from 'vscode'; import { MigrationWizardPage } from '../models/migrationWizardPage'; -import { MigrationStateModel, StateChangeEvent } from '../models/stateMachine'; +import { LoginMigrationValidationResult, MigrationStateModel, StateChangeEvent, ValidateLoginMigrationValidationState } from '../models/stateMachine'; import * as constants from '../constants/strings'; import { debounce, getLoginStatusImage, getLoginStatusMessage, getSourceLogins } from '../api/utils'; import * as styles from '../constants/styles'; @@ -17,6 +17,10 @@ import { getTelemetryProps, logError, sendSqlMigrationActionEvent, TelemetryActi import { CollectingSourceLoginsFailed, CollectingTargetLoginsFailed } from '../models/loginMigrationModel'; import { WizardController } from './wizardController'; import { Tab } from 'azdata'; +import { LoginPreMigrationValidationDialog } from '../dialog/loginMigration/loginPreMigrationValidationDialog'; +import { EOL } from 'os'; + +const VALIDATE_LOGIN_MIGRATION_CUSTOM_BUTTON_INDEX = 0; export class LoginSelectorPage extends MigrationWizardPage { private _view!: azdata.ModelView; @@ -57,6 +61,10 @@ export class LoginSelectorPage extends MigrationWizardPage { }).component(); flex.addItem(await this.createRootContainer(view), { flex: '1 1 auto' }); + this._disposables.push( + this.wizard.customButtons[VALIDATE_LOGIN_MIGRATION_CUSTOM_BUTTON_INDEX].onClick( + async e => await this._validateLoginPreMigration())); + this._disposables.push(this._view.onClosed(e => { this._disposables.forEach( d => { try { d.dispose(); } catch { } }); @@ -71,8 +79,11 @@ export class LoginSelectorPage extends MigrationWizardPage { constants.WIZARD_CANCEL_REASON_NEED_TO_REVIEW_LOGIN_SELECTION ]); + this.wizard.customButtons[VALIDATE_LOGIN_MIGRATION_CUSTOM_BUTTON_INDEX].hidden = false; + this._isCurrentPage = true; this.updateNextButton(); + this.wizard.registerNavigationValidator((pageChangeInfo) => { this.wizard.message = { text: '', @@ -83,9 +94,9 @@ export class LoginSelectorPage extends MigrationWizardPage { return true; } - if (this.selectedLogins().length === 0) { + if (this.selectedLogins().length === 0 || !this.migrationStateModel.isLoginMigrationTargetValidated) { this.wizard.message = { - text: constants.SELECT_LOGIN_TO_CONTINUE, + text: constants.SELECT_LOGIN_AND_RUN_VALIDATION_TO_CONTINUE, level: azdata.window.MessageLevel.Error }; return false; @@ -93,7 +104,7 @@ export class LoginSelectorPage extends MigrationWizardPage { if (this.migrationStateModel._loginMigrationModel.selectedWindowsLogins && !this.migrationStateModel._aadDomainName) { this.wizard.message = { - text: constants.ENTER_AAD_DOMAIN_NAME, + text: constants.ENTER_ENTRA_ID, level: azdata.window.MessageLevel.Error }; return false; @@ -110,6 +121,8 @@ export class LoginSelectorPage extends MigrationWizardPage { } public async onPageLeave(): Promise { + this.wizard.customButtons[VALIDATE_LOGIN_MIGRATION_CUSTOM_BUTTON_INDEX].hidden = true; + this.wizard.registerNavigationValidator((pageChangeInfo) => { return true; }); @@ -151,7 +164,7 @@ export class LoginSelectorPage extends MigrationWizardPage { // target user name const aadDomainNameLabel = this._view.modelBuilder.text() .withProps({ - value: constants.LOGIN_MIGRATIONS_AAD_DOMAIN_NAME_INPUT_BOX_LABEL, + value: constants.LOGIN_MIGRATIONS_ENTRA_ID_INPUT_BOX_LABEL, requiredIndicator: false, CSSStyles: { ...styles.LABEL_CSS } }).component(); @@ -160,7 +173,7 @@ export class LoginSelectorPage extends MigrationWizardPage { .withProps({ width: '300px', inputType: 'text', - placeHolder: constants.LOGIN_MIGRATIONS_AAD_DOMAIN_NAME_INPUT_BOX_PLACEHOLDER, + placeHolder: constants.LOGIN_MIGRATIONS_ENTRA_ID_INPUT_BOX_PLACEHOLDER, required: false, }).component(); @@ -680,6 +693,56 @@ export class LoginSelectorPage extends MigrationWizardPage { await this._loginSelectorTable.updateProperty("height", selectedWindowsLogins ? 600 : 650); } + public updateValidationResultUI(initializing?: boolean): void { + const succeeded = this.migrationStateModel.isLoginMigrationTargetValidated; + if (succeeded) { + this.wizard.message = { + level: azdata.window.MessageLevel.Information, + text: constants.LOGIN_MIGRATION_VALIDATION_MESSAGE_SUCCESS, + }; + } else { + const results = this.migrationStateModel._validateLoginMigration; + const hasResults = results.length > 0; + if (initializing && !hasResults) { + return; + } + + const canceled = results.some(result => result.state === ValidateLoginMigrationValidationState.Canceled); + const errors: string[] = results.flatMap(result => result.errors) ?? []; + const errorsMessage: string = errors.join(EOL); + const hasErrors = errors.length > 0; + const msg = hasResults + ? hasErrors + ? canceled + ? constants.VALIDATION_MESSAGE_CANCELED_ERRORS(errorsMessage) + : constants.VALIDATE_LOGIN_MIGRATION_VALIDATION_COMPLETED_ERRORS(errorsMessage) + : constants.VALIDATION_MESSAGE_CANCELED + : constants.VALIDATION_MESSAGE_NOT_RUN; + + this.wizard.message = { + level: azdata.window.MessageLevel.Error, + text: msg, + }; + } + } + + private async _validateLoginPreMigration(): Promise { + if (this.migrationStateModel?._loginMigrationModel.loginsForMigration?.length <= 0) { + this.wizard.message = { + text: constants.SELECT_LOGIN_TO_CONTINUE, + level: azdata.window.MessageLevel.Error + }; + return; + } + + const dialog = new LoginPreMigrationValidationDialog( + this.migrationStateModel, + () => this.updateValidationResultUI()); + let results: LoginMigrationValidationResult[] = []; + results = this.migrationStateModel._validateLoginMigration; + await dialog.openDialog(constants.VALIDATION_DIALOG_TITLE, results); + } + private async updateValuesOnSelection() { const selectedLogins = this.selectedLogins() || []; await this._loginCount.updateProperties({ @@ -688,7 +751,6 @@ export class LoginSelectorPage extends MigrationWizardPage { this._loginSelectorTable.data?.length || 0) }); - this.migrationStateModel._loginMigrationModel.loginsForMigration = selectedLogins; this.migrationStateModel._loginMigrationModel.loginsForMigration = selectedLogins; await this.refreshAADInputBox(); this.updateNextButton(); diff --git a/extensions/sql-migration/src/wizard/wizardController.ts b/extensions/sql-migration/src/wizard/wizardController.ts index e64c0edbad3..38a91149416 100644 --- a/extensions/sql-migration/src/wizard/wizardController.ts +++ b/extensions/sql-migration/src/wizard/wizardController.ts @@ -218,11 +218,17 @@ export class WizardController { this._wizardObject.nextButton.secondary = false; this._wizardObject.cancelButton.hidden = true; + const validateButton = azdata.window.createButton( + loc.RUN_VALIDATION, + 'left'); + validateButton.secondary = false; + validateButton.hidden = true; + const customCancelButton = azdata.window.createButton( loc.CANCEL, 'right'); customCancelButton.secondary = true; - this._wizardObject.customButtons = [customCancelButton]; + this._wizardObject.customButtons = [validateButton, customCancelButton]; const targetSelectionPage = new LoginMigrationTargetSelectionPage(this._wizardObject, stateModel, this);