From a73929e5e7ef1f8952cd619b1426758fa597fbc4 Mon Sep 17 00:00:00 2001 From: Steven Marturano <71411280+smartura@users.noreply.github.com> Date: Fri, 15 Sep 2023 18:19:04 -0400 Subject: [PATCH] Added validations to configure dialog (#24418) * Added validations to configure dialog * Improved validations messages text * Addressed PR feedback * Addressed PR feedback * Fixed build issue * Version bump * Changed to single quotes --- extensions/sql-migration/config.json | 2 +- .../sql-migration/src/constants/strings.ts | 12 +- .../tdeConfigurationDialog.ts | 186 +++++++++++++++++- .../sql-migration/src/models/stateMachine.ts | 51 ++++- .../sql-migration/src/models/tdeModels.ts | 28 ++- .../sql-migration/src/service/contracts.ts | 27 +++ .../sql-migration/src/service/features.ts | 31 ++- 7 files changed, 330 insertions(+), 7 deletions(-) diff --git a/extensions/sql-migration/config.json b/extensions/sql-migration/config.json index bb365ff892..42f90b9593 100644 --- a/extensions/sql-migration/config.json +++ b/extensions/sql-migration/config.json @@ -1,7 +1,7 @@ { "downloadUrl": "https://github.com/Microsoft/sqltoolsservice/releases/download/{#version#}/microsoft.sqltools.migration-{#fileName#}", "useDefaultLinuxRuntime": true, - "version": "4.7.0.13", + "version": "4.10.0.3", "downloadFileNames": { "Windows_86": "win-x86-net7.0.zip", "Windows": "win-x64-net7.0.zip", diff --git a/extensions/sql-migration/src/constants/strings.ts b/extensions/sql-migration/src/constants/strings.ts index 5a657ee0e1..aa690be09e 100644 --- a/extensions/sql-migration/src/constants/strings.ts +++ b/extensions/sql-migration/src/constants/strings.ts @@ -1516,8 +1516,18 @@ export const TDE_WIZARD_MSG_MANUAL = localize('sql.migration.tde.msg.manual', "Y export const TDE_WIZARD_MSG_TDE = localize('sql.migration.tde.msg.tde', "You have given Azure Data Studio access to migrate the encryption certificates and database."); export const TDE_WIZARD_MSG_EMPTY = localize('sql.migration.tde.msg.empty', "No encrypted database selected."); +export const TDE_VALIDATION_TITLE = localize('sql.migration.tde.validation.title', "Validation"); +export const TDE_VALIDATION_REQUIREMENTS_MESSAGE = localize('sql.migration.tde.validation.requirements.message', "In order for certificate migration to succeed, you must meet all of the requirements listed below.\n\nClick \"Run validation\" to check that requirements are met."); +export const TDE_VALIDATION_STATUS_PENDING = localize('sql.migration.tde.validation.status.pending', "Pending"); +export const TDE_VALIDATION_STATUS_RUNNING = localize('sql.migration.tde.validation.running', "Running"); +export const TDE_VALIDATION_STATUS_SUCCEEDED = localize('sql.migration.tde.validation.status.succeeded', "Succeeded"); +export const TDE_VALIDATION_STATUS_RUN_VALIDATION = localize('sql.migration.tde.validation.run.validation', "Run validation"); +export const TDE_VALIDATION_DESCRIPTION = localize('sql.migration.tde.validation.description', "Description"); +export const TDE_VALIDATION_ERROR = localize('sql.migration.tde.validation.error', "Error"); +export const TDE_VALIDATION_TROUBLESHOOTING_TIPS = localize('sql.migration.tde.validation.troubleshooting.tips', "Troubleshooting tips"); + export function TDE_MIGRATION_ERROR(message: string): string { - return localize('sql.migration.starting.migration.error', "An error occurred while starting the certificate migration: '{0}'", message); + return localize('sql.migration.starting.migration.error', "The following error has occurred while starting the certificate migration: '{0}'", message); } export function TDE_MIGRATION_ERROR_DB(name: string, message: string): string { diff --git a/extensions/sql-migration/src/dialog/tdeConfiguration/tdeConfigurationDialog.ts b/extensions/sql-migration/src/dialog/tdeConfiguration/tdeConfigurationDialog.ts index e224cf3f3a..2d32de0587 100644 --- a/extensions/sql-migration/src/dialog/tdeConfiguration/tdeConfigurationDialog.ts +++ b/extensions/sql-migration/src/dialog/tdeConfiguration/tdeConfigurationDialog.ts @@ -9,7 +9,9 @@ import { MigrationStateModel } from '../../models/stateMachine'; import * as constants from '../../constants/strings'; import * as styles from '../../constants/styles'; import * as utils from '../../api/utils'; +import { EOL } from 'os'; import { ConfigDialogSetting } from '../../models/tdeModels' +import { IconPathHelper } from '../../constants/iconPathHelper'; export class TdeConfigurationDialog { @@ -22,8 +24,12 @@ export class TdeConfigurationDialog { private _adsConfirmationCheckBox!: azdata.CheckBoxComponent; private _manualMethodWarningContainer!: azdata.FlexContainer; private _networkPathText!: azdata.InputBoxComponent; + private _validationTable!: azdata.TableComponent; + private _validationMessagesText!: azdata.InputBoxComponent; private _onClosed: () => void; + private _validationSuccessDescriptionErrorAndTips!: string[][]; + constructor(public migrationStateModel: MigrationStateModel, onClosed: () => void) { this._onClosed = onClosed; } @@ -130,6 +136,24 @@ export class TdeConfigurationDialog { adsMethodButton.onDidChangeCheckedState(async checked => { if (checked) { this.migrationStateModel.tdeMigrationConfig.setPendingTdeMigrationMethod(ConfigDialogSetting.ExportCertificates); + let validationTitleData = await this.migrationStateModel.getTdeValidationTitles(); + + let networkPathValidated = + (this.migrationStateModel.tdeMigrationConfig.getPendingNetworkPath() !== '') && + (this.migrationStateModel.tdeMigrationConfig.getPendingNetworkPath() === this.migrationStateModel.tdeMigrationConfig.getLastValidatedNetworkPath()) + + let result = validationTitleData.result.map(validationTitle => { + return [ + validationTitle, + { + 'icon': networkPathValidated ? IconPathHelper.completedMigration : IconPathHelper.notFound, + 'title': networkPathValidated ? constants.TDE_VALIDATION_STATUS_SUCCEEDED : constants.TDE_VALIDATION_STATUS_PENDING + }, + networkPathValidated ? constants.TDE_VALIDATION_STATUS_SUCCEEDED : constants.TDE_VALIDATION_STATUS_PENDING + ] + }); + + await this._validationTable.updateProperty('data', result) await this.updateUI(); } })); @@ -228,6 +252,7 @@ export class TdeConfigurationDialog { this._disposables.push( this._networkPathText.onTextChanged(async networkPath => { this.migrationStateModel.tdeMigrationConfig.setPendingNetworkPath(networkPath); + await this.updateUI(); })); this._adsConfirmationCheckBox = _view.modelBuilder.checkBox() @@ -245,15 +270,172 @@ export class TdeConfigurationDialog { await this.updateUI(); })); + const preValidationSeparator = _view.modelBuilder.separator().component(); + + const validationRequiredLabel = _view.modelBuilder.text() + .withProps({ + value: constants.TDE_VALIDATION_REQUIREMENTS_MESSAGE, + CSSStyles: { + ...styles.BODY_CSS, + 'margin': '4px 2px 4px 2px' + } + }).component(); + + const runValidationButton = _view.modelBuilder.button() + .withProps( + { + label: constants.TDE_VALIDATION_STATUS_RUN_VALIDATION, + enabled: true + }).component(); + + this._disposables.push( + runValidationButton.onDidClick(async (e) => { + let data = this._validationTable.data.map((e) => { + return [ + e[0], + { + 'icon': IconPathHelper.inProgressMigration, + 'title': constants.TDE_VALIDATION_STATUS_RUNNING + }, + constants.TDE_VALIDATION_STATUS_RUNNING + ] + }); + await this._validationTable.updateProperty('data', data); + + let validationData = await this.migrationStateModel.runTdeValidation( + this.migrationStateModel.tdeMigrationConfig.getPendingNetworkPath()); + + let allValidationsSucceeded = true; + + this._validationSuccessDescriptionErrorAndTips = validationData.result.map(e => { + return [ + e.validationStatus.toString(), + e.validationDescription, + e.validationErrorMessage, + e.validationTroubleshootingTips + ] + }); + + let res = validationData.result.map(e => { + if (e.validationStatus < 0) { + allValidationsSucceeded = false; + } + + return [ + e.validationTitle, + { + 'icon': e.validationStatus > 0 ? IconPathHelper.completedMigration : IconPathHelper.error, + 'title': e.validationStatusString + }, + e.validationStatusString + ] + }); + + await this._validationTable.updateProperty('data', res); + + if (allValidationsSucceeded) { + this.migrationStateModel.tdeMigrationConfig.setLastValidatedNetworkPath( + this.migrationStateModel.tdeMigrationConfig.getPendingNetworkPath()); + await this.updateUI(); + } + })); + + this._validationTable = this._createValidationTable(_view); + + this._disposables.push( + this._validationTable.onRowSelected( + async (e) => { + const selectedRows: number[] = this._validationTable.selectedRows ?? []; + + let message: string = ''; + selectedRows.forEach((rowIndex) => { + + let successful = this._validationSuccessDescriptionErrorAndTips[rowIndex][0] === "1" // Value will be "1" if successful + let description = this._validationSuccessDescriptionErrorAndTips[rowIndex][1]; + let errorMessage = this._validationSuccessDescriptionErrorAndTips[rowIndex][2]; + let tips = this._validationSuccessDescriptionErrorAndTips[rowIndex][3]; + + message = `${constants.TDE_VALIDATION_DESCRIPTION}:${EOL}${description}`; + if (!successful) { + message += `${EOL}${EOL}`; + if (errorMessage?.length > 0) { + message += `${constants.TDE_VALIDATION_ERROR}:${EOL}${errorMessage}${EOL}${EOL}`; + } + + message += `${constants.TDE_VALIDATION_TROUBLESHOOTING_TIPS}:${EOL}${tips}`; + } + }); + + this._validationMessagesText.value = message; + })); + + this._validationMessagesText = _view.modelBuilder.inputBox() + .withProps({ + inputType: 'text', + height: 142, + multiline: true, + CSSStyles: { 'overflow': 'none auto' } + }) + .component(); + + const postValidationSeparator = _view.modelBuilder.separator().component(); + container.addItems([ adsMethodInfoMessage, networkPathLabel, this._networkPathText, - this._adsConfirmationCheckBox]); + this._adsConfirmationCheckBox, + preValidationSeparator, + validationRequiredLabel, + runValidationButton, + this._validationTable, + this._validationMessagesText, + postValidationSeparator + ]); return container; } + private _createValidationTable(view: azdata.ModelView): azdata.TableComponent { + return view.modelBuilder.table() + .withProps({ + columns: [ + { + value: 'title', + name: constants.TDE_VALIDATION_TITLE, + type: azdata.ColumnType.text, + width: 320, + headerCssClass: 'no-borders', + cssClass: 'no-borders align-with-header', + }, + { + value: 'image', + name: '', + type: azdata.ColumnType.icon, + width: 30, + headerCssClass: 'no-borders display-none', + cssClass: 'no-borders align-with-header', + }, + { + value: 'message', + name: constants.TDE_MIGRATE_COLUMN_STATUS, + type: azdata.ColumnType.text, + width: 100, + headerCssClass: 'no-borders', + cssClass: 'no-borders align-with-header', + }, + ], + data: [], + width: 450, + height: 80, + CSSStyles: { + 'margin-top': '10px', + 'margin-bottom': '10px', + }, + }) + .component(); + } + private createManualWarningContainer(_view: azdata.ModelView): azdata.FlexContainer { const container = _view.modelBuilder.flexContainer().withProps({ CSSStyles: { @@ -293,7 +475,7 @@ export class TdeConfigurationDialog { await utils.updateControlDisplay(this._adsMethodConfirmationContainer, exportCertsUsingAds); await utils.updateControlDisplay(this._manualMethodWarningContainer, this.migrationStateModel.tdeMigrationConfig.getPendingConfigDialogSetting() === ConfigDialogSetting.DoNotExport); - this.dialog!.okButton.enabled = this.migrationStateModel.tdeMigrationConfig.isAnyChangeReadyToBeApplied() + this.dialog!.okButton.enabled = this.migrationStateModel.tdeMigrationConfig.isAnyChangeReadyToBeApplied(); } public async openDialog(dialogName?: string,) { diff --git a/extensions/sql-migration/src/models/stateMachine.ts b/extensions/sql-migration/src/models/stateMachine.ts index eeac2ab910..4fec86f3de 100644 --- a/extensions/sql-migration/src/models/stateMachine.ts +++ b/extensions/sql-migration/src/models/stateMachine.ts @@ -17,7 +17,7 @@ import { hashString, deepClone, getBlobContainerNameWithFolder, Blob, getLastBac import { SKURecommendationPage } from '../wizard/skuRecommendationPage'; import { excludeDatabases, getEncryptConnectionValue, getSourceConnectionId, getSourceConnectionProfile, getSourceConnectionServerInfo, getSourceConnectionString, getSourceConnectionUri, getTrustServerCertificateValue, SourceDatabaseInfo, TargetDatabaseInfo } from '../api/sqlUtils'; import { LoginMigrationModel } from './loginMigrationModel'; -import { TdeMigrationDbResult, TdeMigrationModel } from './tdeModels'; +import { TdeMigrationDbResult, TdeMigrationModel, TdeValidationResult } from './tdeModels'; import { NetworkInterfaceModel } from '../api/dataModels/azure/networkInterfaceModel'; const localize = nls.loadMessageBundle(); @@ -1000,6 +1000,55 @@ export class MigrationStateModel implements Model, vscode.Disposable { return opResult; } + public async getTdeValidationTitles(): Promise> { + const opResult: OperationResult = { + success: false, + result: [], + errors: [] + }; + + try { + opResult.result = await this.migrationService.getTdeValidationTitles() ?? []; + } catch (e) { + console.error(e); + } + + return opResult; + } + + public async runTdeValidation(networkSharePath: string): Promise> { + const opResult: OperationResult = { + success: false, + result: [], + errors: [] + }; + + const connectionString = await getSourceConnectionString(); + + try { + let tdeValidationResult = await this.migrationService.runTdeValidation( + connectionString, + networkSharePath); + + if (tdeValidationResult !== undefined) { + opResult.result = tdeValidationResult?.map((e) => { + return { + validationTitle: e.validationTitle, + validationDescription: e.validationDescription, + validationTroubleshootingTips: e.validationTroubleshootingTips, + validationErrorMessage: e.validationErrorMessage, + validationStatus: e.validationStatus, + validationStatusString: e.validationStatusString + }; + }); + } + } catch (e) { + console.error(e); + } + + return opResult; + } + public async startMigration() { const currentConnection = await getSourceConnectionProfile(); const isOfflineMigration = this._databaseBackup.migrationMode === MigrationMode.OFFLINE; diff --git a/extensions/sql-migration/src/models/tdeModels.ts b/extensions/sql-migration/src/models/tdeModels.ts index a528c37192..f06f2f1836 100644 --- a/extensions/sql-migration/src/models/tdeModels.ts +++ b/extensions/sql-migration/src/models/tdeModels.ts @@ -41,6 +41,15 @@ export interface TdeMigrationDbResult { message: string; } +export interface TdeValidationResult { + validationTitle: string; + validationDescription: string; + validationTroubleshootingTips: string; + validationErrorMessage: string; + validationStatus: number; + validationStatusString: string; +} + export class TdeMigrationModel { // Settings for which the user has clicked the apply button @@ -53,6 +62,9 @@ export class TdeMigrationModel { private _pendingExportCertUserConsent: boolean; private _pendingNetworkPath: string; + // Last network path for which all validations succeeded + private _lastValidatedNetworkPath: string; + private _configurationCompleted: boolean; private _shownBefore: boolean; private _encryptedDbs: string[]; @@ -75,6 +87,7 @@ export class TdeMigrationModel { this._appliedExportCertUserConsent = false; this._pendingExportCertUserConsent = false; this._tdeMigrationCompleted = false; + this._lastValidatedNetworkPath = ''; this._tdeMigrationCompleted = this._tdeMigrationCompleted; } @@ -176,7 +189,12 @@ export class TdeMigrationModel { } if (this._pendingConfigDialogSetting === ConfigDialogSetting.ExportCertificates) { - return this._pendingExportCertUserConsent; + if (this._pendingNetworkPath !== this._lastValidatedNetworkPath) { + return false; + } + + return this._pendingNetworkPath.length > 0 && + this._pendingExportCertUserConsent; } return true; @@ -213,4 +231,12 @@ export class TdeMigrationModel { public setPendingExportCertUserConsent(pendingExportCertUserConsent: boolean) { this._pendingExportCertUserConsent = pendingExportCertUserConsent; } + + public setLastValidatedNetworkPath(validatedNetworkPath: string) { + this._lastValidatedNetworkPath = validatedNetworkPath; + } + + public getLastValidatedNetworkPath() { + return this._lastValidatedNetworkPath; + } } diff --git a/extensions/sql-migration/src/service/contracts.ts b/extensions/sql-migration/src/service/contracts.ts index 138ccd8dfc..2f3397a6c0 100644 --- a/extensions/sql-migration/src/service/contracts.ts +++ b/extensions/sql-migration/src/service/contracts.ts @@ -388,6 +388,11 @@ export const enum VirtualMachineFamily { standardNVSv4Family } +export const enum TdeValidationStatus { + Failed = -1, + Succeeded = 1 +} + export namespace GetSqlMigrationSkuRecommendationsRequest { export const type = new RequestType('migration/getskurecommendations'); } @@ -549,3 +554,25 @@ export interface TdeMigrateProgressParams { message: string; statusCode: string; } + +export interface TdeValidationResult { + validationTitle: string; + validationDescription: string; + validationTroubleshootingTips: string; + validationErrorMessage: string; + validationStatus: TdeValidationStatus; + validationStatusString: string; +} + +export interface TdeValidationParams { + sourceSqlConnectionString: string; + networkSharePath: string; +} + +export namespace TdeValidationRequest { + export const type = new RequestType('migration/tdevalidation'); +} + +export namespace TdeValidationTitlesRequest { + export const type = new RequestType<{}, string[], void, void>('migration/tdevalidationtitles'); +} diff --git a/extensions/sql-migration/src/service/features.ts b/extensions/sql-migration/src/service/features.ts index f4d6f846f5..f061c6ca62 100644 --- a/extensions/sql-migration/src/service/features.ts +++ b/extensions/sql-migration/src/service/features.ts @@ -316,5 +316,34 @@ export class SqlMigrationService extends MigrationExtensionService implements co return undefined; } -} + async runTdeValidation( + sourceSqlConnectionString: string, + networkSharePath: string, + ) { + let params: contracts.TdeValidationParams = { + sourceSqlConnectionString: sourceSqlConnectionString, + networkSharePath: networkSharePath, + }; + + try { + return await this._client.sendRequest(contracts.TdeValidationRequest.type, params); + } + catch (e) { + this._client.logFailedRequest(contracts.TdeValidationRequest.type, e); + } + + return undefined; + } + + async getTdeValidationTitles() { + try { + return await this._client.sendRequest(contracts.TdeValidationTitlesRequest.type, {}); + } + catch (e) { + this._client.logFailedRequest(contracts.TdeValidationRequest.type, e); + } + + return undefined; + } +}