diff --git a/extensions/mssql/config.json b/extensions/mssql/config.json index 9e48c67617..c4fe0fb754 100644 --- a/extensions/mssql/config.json +++ b/extensions/mssql/config.json @@ -1,6 +1,6 @@ { "downloadUrl": "https://github.com/Microsoft/sqltoolsservice/releases/download/{#version#}/microsoft.sqltools.servicelayer-{#fileName#}", - "version": "4.4.0.22", + "version": "4.4.1.4", "downloadFileNames": { "Windows_86": "win-x86-net6.0.zip", "Windows_64": "win-x64-net6.0.zip", diff --git a/extensions/sql-migration/package.json b/extensions/sql-migration/package.json index 4d9bdd80f4..6eedb1452d 100644 --- a/extensions/sql-migration/package.json +++ b/extensions/sql-migration/package.json @@ -2,7 +2,7 @@ "name": "sql-migration", "displayName": "%displayName%", "description": "%description%", - "version": "1.2.2", + "version": "1.2.3", "publisher": "Microsoft", "preview": false, "license": "https://raw.githubusercontent.com/Microsoft/azuredatastudio/main/LICENSE.txt", diff --git a/extensions/sql-migration/src/constants/strings.ts b/extensions/sql-migration/src/constants/strings.ts index c5d681334f..c86848789f 100644 --- a/extensions/sql-migration/src/constants/strings.ts +++ b/extensions/sql-migration/src/constants/strings.ts @@ -1355,3 +1355,100 @@ export function RESULTS_INFO_BOX_STATUS(state: string | undefined, errors?: stri "Step status: {0}", status); } } + + +//TDE Configuration Dialog +export const TDE_WIZARD_TITLE = localize('sql.migration.tde.wizard.title', "Encrypted database selected."); +export const TDE_WIZARD_DESCRIPTION = localize('sql.migration.tde.wizard.description', "To migrate an encrypted database successfully you need to provide access to the encryption certificates or migrate certificates manually before proceeding with the migration. {0}."); +export const TDE_WIZARD_MIGRATION_CAPTION = localize('sql.migration.tde.wizard.optionscaption', "Certificate migration"); +export const TDE_WIZARD_MIGRATION_OPTION_ADS = localize('sql.migration.tde.wizard.optionads', "Export my certificates and private key to the target."); +export const TDE_WIZARD_MIGRATION_OPTION_ADS_CONFIRM = localize('sql.migration.tde.wizard.optionadsconfirm', "I give consent to use my credentials for accessing the certificates."); +export const TDE_WIZARD_MIGRATION_OPTION_MANUAL = localize('sql.migration.tde.wizard.optionmanual', "I don't want Azure Data Studio to export the certificates."); +export const TDE_BUTTON_CAPTION = localize('sql.migration.tde.button.caption', "Edit"); +export const TDE_WIZARD_MSG_MANUAL = localize('sql.migration.tde.msg.manual', "You have chosen to manually migrate certificates."); +export const TDE_WIZARD_MSG_TDE = localize('sql.migration.tde.msg.tde', "You have given access to Azure Data Studio to migrate the encryption certificates and database."); +export const TDE_WIZARD_MSG_EMPTY = localize('sql.migration.tde.msg.empty', "No encrypted database selected."); + +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); +} + +export function TDE_MIGRATION_ERROR_DB(name: string, message: string): string { + return localize('sql.migration.starting.migration.dberror', "Error migrating certificate for database {0}. {1}", name, message); +} + +export function TDE_MSG_DATABASES_SELECTED(selected: number, message: string): string { + return localize('sql.migration.tde.msg.databases.selected', "{0} Transparent Data Encryption enabled databases selected for migration. {1}", selected, message); +} + +export function TDE_WIZARD_DATABASES_SELECTED(encryptedCount: number, totalCount: number): string { + return localize('sql.migration.tde.wizard.databases.selected', "{0} out of {1} selected database(s) is using transparent data encryption.", encryptedCount, totalCount); +} + + +export const TDE_WIZARD_MIGRATION_OPTION_MANUAL_WARNING = localize('sql.migration.tde.wizard.optionmanual.warning', "You must migrate the certificates before proceeding with the migration otherwise the migration will fail. {0}."); + +export const TDE_WIZARD_ADS_CERTS_INFO = localize('sql.migration.network.share.header.text', "Please enter a location where the SQL Server will export the certificates. Also verify that SQL Server service has write access to this path and the current user should have administrator privileges on the computer where this network path is."); + +export const TDE_WIZARD_CERTS_NETWORK_SHARE_LABEL = localize('sql.migration.tde.wizard.network.share.label', "Network path for certificate"); +export const TDE_WIZARD_CERTS_NETWORK_SHARE_PLACEHOLDER = localize('sql.migration.tde.wizard.network.share.placeholder', "Enter network path"); +export const TDE_WIZARD_CERTS_NETWORK_SHARE_INFO = localize('sql.migration.tde.wizard.network.share.info', "Network path where certificate will be placed."); + +export const TDE_MIGRATE_BUTTON = localize('sql.migration.tde.button.migrate', "Migrate certificates"); + + +export const STATE_CANCELED = localize('sql.migration.state.canceled', "Canceled"); +export const STATE_PENDING = localize('sql.migration.state.pending', "Pending"); +export const STATE_RUNNING = localize('sql.migration.state.running', "Running"); +export const STATE_SUCCEEDED = localize('sql.migration.state.succeeded', "Succeeded"); +export const STATE_FAILED = localize('sql.migration.state.failed', "Failed"); + +export const TDE_MIGRATEDIALOG_TITLE = localize('sql.migration.validation.dialog.title', "Certificates Migration"); +export const TDE_MIGRATE_DONE_BUTTON = localize('sql.migration.tde.migrate.done.button', "Done"); +export const TDE_MIGRATE_HEADING = localize('sql.migration.tde.migrate.heading', "Migrating the certificates from the following databases:"); + + +export const TDE_MIGRATE_COLUMN_DATABASES = localize('sql.migration.tde.migrate.column.databases', "Databases"); +export const TDE_MIGRATE_COLUMN_STATUS = localize('sql.migration.tde.migrate.column.status', "Status"); +export const TDE_MIGRATE_RETRY_VALIDATION = localize('sql.migration.tde.migrate.start.validation', "Retry migration"); +export const TDE_MIGRATE_COPY_RESULTS = localize('sql.migration.tde.migrate.copy.results', "Copy migration results"); +export const TDE_MIGRATE_RESULTS_HEADING = localize('sql.migration.tde.migrate.results.heading', "Certificates migration progress details:"); +export const TDE_MIGRATE_RESULTS_HEADING_PREVIOUS = localize('sql.migration.tde.migrate.results.heading.previous', "Previous certificates migration results:"); +export const TDE_MIGRATE_RESULTS_HEADING_COMPLETED = localize('sql.migration.tde.migrate.results.heading.completed', "Certificates migration results:"); +export const TDE_MIGRATE_VALIDATION_COMPLETED = localize('sql.migration.tde.migrate.validation.completed', "Migration completed successfully."); +export const TDE_MIGRATE_VALIDATION_CANCELED = localize('sql.migration.tde.migrate.validation.camceled', "Migration canceled"); + +export function TDE_MIGRATE_VALIDATION_COMPLETED_ERRORS(msg: string): string { + return localize( + 'sql.migration.tde.migrate.completed.errors', + "Migration completed with the following error(s):{0}{1}", EOL, msg); +} +export function TDE_MIGRATE_VALIDATION_STATUS(state: string | undefined, errors: string): string { + const status = state ?? ''; + return localize( + 'sql.migration.tde.migrate.status.details', + "Migration status: {0}{1}{2}", status, EOL, errors); +} + +export const TDE_MIGRATE_MESSAGE_SUCCESS = localize('sql.migration.tde.migrate.success', "Certificates migration completed successfully. Please click Next to proceed with the migration."); +export function TDE_MIGRATE_MESSAGE_CANCELED_ERRORS(msg: string): string { + return localize( + 'sql.migration.tde.migrate.canceled.errors', + "Validation was canceled with the following error(s):{0}{1}", EOL, msg); +} +export const TDE_MIGRATE_MESSAGE_CANCELED = localize('sql.migration.tde.migrate.canceled', "Certificates migration was canceled. Please run and complete the certificates migration to continue."); +export const TDE_MIGRATE_MESSAGE_NOT_RUN = localize('sql.migration.tde.migrate.not.run', "Certificates migration has not been run for the current configuration. Please run and complete the certificates migration to continue."); + +export function TDE_MIGRATE_STATUS_ERROR(state: string, error: string): string { + const status = state ?? ''; + return localize( + 'sql.migration.tde.migrate.status.error', + "{0}{1}{2}", + status, + EOL, + error); +} + +export function TDE_COMPLETED_STATUS(completed: number, total: number): string { + return localize('sql.migration.tde.progress.update', "{0} of {1} completed", completed, total); +} diff --git a/extensions/sql-migration/src/dashboard/dashboardTab.ts b/extensions/sql-migration/src/dashboard/dashboardTab.ts index 309261f26b..0e7443b31d 100644 --- a/extensions/sql-migration/src/dashboard/dashboardTab.ts +++ b/extensions/sql-migration/src/dashboard/dashboardTab.ts @@ -141,6 +141,7 @@ export class DashboardTab extends TabBase { const toolbar = view.modelBuilder.toolbarContainer(); toolbar.addToolbarItems([ { component: this.createNewMigrationButton() }, + { component: this.createNewLoginMigrationButton() }, { component: this.createNewSupportRequestButton() }, { component: this.createFeedbackButton() }, ]); diff --git a/extensions/sql-migration/src/dashboard/migrationsListTab.ts b/extensions/sql-migration/src/dashboard/migrationsListTab.ts index 0ac32b3f24..58b932d809 100644 --- a/extensions/sql-migration/src/dashboard/migrationsListTab.ts +++ b/extensions/sql-migration/src/dashboard/migrationsListTab.ts @@ -144,6 +144,7 @@ export class MigrationsListTab extends TabBase { toolbar.addToolbarItems([ { component: this.createNewMigrationButton(), toolbarSeparatorAfter: true }, + { component: this.createNewLoginMigrationButton(), toolbarSeparatorAfter: true }, { component: this.createNewSupportRequestButton() }, { component: this.createFeedbackButton(), toolbarSeparatorAfter: true }, { component: this._refreshLoader }, diff --git a/extensions/sql-migration/src/dashboard/sqlServerDashboard.ts b/extensions/sql-migration/src/dashboard/sqlServerDashboard.ts index a820dde103..34208e7ec3 100644 --- a/extensions/sql-migration/src/dashboard/sqlServerDashboard.ts +++ b/extensions/sql-migration/src/dashboard/sqlServerDashboard.ts @@ -444,7 +444,7 @@ export class DashboardWidget { if (serverName) { const api = (await vscode.extensions.getExtension(mssql.extension.name)?.activate()) as mssql.IExtension; if (api) { - this.stateModel = new MigrationStateModel(this._context, connectionId, api.sqlMigration); + this.stateModel = new MigrationStateModel(this._context, connectionId, api.sqlMigration, api.tdeMigration); this._context.subscriptions.push(this.stateModel); const savedInfo = this.checkSavedInfo(serverName); if (savedInfo) { @@ -483,7 +483,7 @@ export class DashboardWidget { if (serverName) { const api = (await vscode.extensions.getExtension(mssql.extension.name)?.activate()) as mssql.IExtension; if (api) { - this.stateModel = new MigrationStateModel(this._context, connectionId, api.sqlMigration); + this.stateModel = new MigrationStateModel(this._context, connectionId, api.sqlMigration, api.tdeMigration); this._context.subscriptions.push(this.stateModel); const wizardController = new WizardController( this._context, diff --git a/extensions/sql-migration/src/dialog/retryMigration/retryMigrationDialog.ts b/extensions/sql-migration/src/dialog/retryMigration/retryMigrationDialog.ts index 2641106bd5..a62799ff82 100644 --- a/extensions/sql-migration/src/dialog/retryMigration/retryMigrationDialog.ts +++ b/extensions/sql-migration/src/dialog/retryMigration/retryMigrationDialog.ts @@ -32,7 +32,7 @@ export class RetryMigrationDialog { api: mssql.IExtension, location: azureResource.AzureLocation): Promise { - const stateModel = new MigrationStateModel(this._context, connectionId, api.sqlMigration); + const stateModel = new MigrationStateModel(this._context, connectionId, api.sqlMigration, api.tdeMigration); const sourceDatabaseName = migration.properties.sourceDatabaseName; const savedInfo: SavedInfo = { closedPage: 0, diff --git a/extensions/sql-migration/src/dialog/tdeConfiguration/tdeConfigurationDialog.ts b/extensions/sql-migration/src/dialog/tdeConfiguration/tdeConfigurationDialog.ts new file mode 100644 index 0000000000..0d6d84fca5 --- /dev/null +++ b/extensions/sql-migration/src/dialog/tdeConfiguration/tdeConfigurationDialog.ts @@ -0,0 +1,343 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import { MigrationStateModel } from '../../models/stateMachine'; +import * as constants from '../../constants/strings'; +import * as styles from '../../constants/styles'; +import * as utils from '../../api/utils'; +import { SKURecommendationPage } from '../../wizard/skuRecommendationPage'; + +export class TdeConfigurationDialog { + + private dialog: azdata.window.Dialog | undefined; + private _isOpen: boolean = false; + + private _disposables: vscode.Disposable[] = []; + + private _adsMethodConfirmationContainer!: azdata.FlexContainer; + private _adsConfirmationCheckBox!: azdata.CheckBoxComponent; + private _manualMethodWarningContainer!: azdata.FlexContainer; + private _networkPathText!: azdata.InputBoxComponent; + private _onClosed: () => void; + + constructor(public skuRecommendationPage: SKURecommendationPage, public wizard: azdata.window.Wizard, public migrationStateModel: MigrationStateModel, + onClosed: () => void) { + this._onClosed = onClosed; + } + + private async initializeDialog(dialog: azdata.window.Dialog): Promise { + return new Promise((resolve, reject) => { + dialog.registerContent(async (view) => { + try { + const flex = this.createContainer(view); + + this._disposables.push(view.onClosed(e => { + this._disposables.forEach( + d => { try { d.dispose(); } catch { } }); + })); + + await view.initializeModel(flex); + resolve(); + } catch (ex) { + reject(ex); + } + }); + }); + } + + private createContainer(_view: azdata.ModelView): azdata.FlexContainer { + const container = _view.modelBuilder.flexContainer().withProps({ + CSSStyles: { + 'margin': '8px 16px', + 'flex-direction': 'column', + } + }).component(); + const encryptedDescriptionText = constants.TDE_WIZARD_DATABASES_SELECTED( + this.migrationStateModel.tdeMigrationConfig.getTdeEnabledDatabasesCount(), + this.migrationStateModel._databasesForMigration.length); + + const encrypted_description1 = _view.modelBuilder.text().withProps({ + value: encryptedDescriptionText, + CSSStyles: { + ...styles.BODY_CSS, + } + }).component(); + const encrypted_description2 = _view.modelBuilder.text().withProps({ + value: constants.TDE_WIZARD_DESCRIPTION, + CSSStyles: { + ...styles.BODY_CSS, + 'margin-top': '8px', + 'text-align': 'justify' + }, + links: [{ + text: constants.LEARN_MORE, + url: 'https://learn.microsoft.com/sql/relational-databases/security/encryption/transparent-data-encryption', + accessibilityInformation: { + label: constants.LEARN_MORE + } + }] + }).component(); + const selectDataSourceRadioButtons = this.createMethodsContainer(_view); + container.addItems([ + encrypted_description1, + encrypted_description2, + selectDataSourceRadioButtons, + ]); + return container; + } + + + private createMethodsContainer(_view: azdata.ModelView): azdata.FlexContainer { + const chooseMethodText = _view.modelBuilder.text().withProps({ + value: constants.TDE_WIZARD_MIGRATION_CAPTION, + CSSStyles: { + ...styles.LABEL_CSS, + 'margin-top': '16px', + } + }).component(); + + const buttonGroup = 'dataSourceContainer'; + const radioButtonContainer = _view.modelBuilder.flexContainer().withProps({ + ariaLabel: constants.AZURE_RECOMMENDATION_CHOOSE_METHOD, + ariaRole: 'radiogroup', + CSSStyles: { + 'flex-direction': 'column' + } + }).component(); + + const adsMethodContainer = _view.modelBuilder.flexContainer() + .withProps( + { + CSSStyles: { + 'flex-direction': 'column', + 'display': 'inline' + } + }) + .component(); + + const adsMethodButton = _view.modelBuilder.radioButton() + .withProps({ + name: buttonGroup, + label: constants.TDE_WIZARD_MIGRATION_OPTION_ADS, + checked: this.migrationStateModel.tdeMigrationConfig.isTdeMigrationMethodAds(), + CSSStyles: { + ...styles.BODY_CSS + }, + }).component(); + this._disposables.push( + adsMethodButton.onDidChangeCheckedState(async checked => { + if (checked) { + this.migrationStateModel.tdeMigrationConfig.setTdeMigrationMethod(true); + await this.updateUI(); + } + })); + + this._adsMethodConfirmationContainer = this.createAdsConfirmationContainer(_view); + + adsMethodContainer.addItems([ + adsMethodButton, + this._adsMethodConfirmationContainer]); + + const manualMethodContainer = _view.modelBuilder.flexContainer() + .withProps( + { + CSSStyles: { + 'flex-direction': 'column', + 'display': 'inline' + } + }) + .component(); + + const manualMethodButton = _view.modelBuilder.radioButton() + .withProps({ + name: buttonGroup, + label: constants.TDE_WIZARD_MIGRATION_OPTION_MANUAL, + checked: this.migrationStateModel.tdeMigrationConfig.isTdeMigrationMethodManual(), + CSSStyles: { ...styles.BODY_CSS } + }).component(); + + this._disposables.push( + manualMethodButton.onDidChangeCheckedState(async checked => { + if (checked) { + this.migrationStateModel.tdeMigrationConfig.setTdeMigrationMethod(false); + await this.updateUI(); + } + })); + + this._manualMethodWarningContainer = this.createManualWarningContainer(_view); + + manualMethodContainer.addItems([ + manualMethodButton, + this._manualMethodWarningContainer + ]); + + radioButtonContainer.addItems([ + adsMethodContainer, + manualMethodContainer]); + + const container = _view.modelBuilder.flexContainer() + .withLayout({ flexFlow: 'column' }) + .withItems([ + chooseMethodText, + radioButtonContainer + ]) + .component(); + + return container; + } + + private createAdsConfirmationContainer(_view: azdata.ModelView): azdata.FlexContainer { + const container = _view.modelBuilder.flexContainer().withProps({ + CSSStyles: { + 'flex-direction': 'column' + } + }).component(); + + const adsMethodInfoMessage = _view.modelBuilder.infoBox() + .withProps({ + text: constants.TDE_WIZARD_ADS_CERTS_INFO, + style: 'information', + CSSStyles: { + ...styles.BODY_CSS, + 'margin': '4px 14px 0px 14px', + 'text-align': 'justify' + } + }).component(); + + const networkPathLabel = _view.modelBuilder.text() + .withProps({ + value: constants.TDE_WIZARD_CERTS_NETWORK_SHARE_LABEL, + description: constants.TDE_WIZARD_CERTS_NETWORK_SHARE_INFO, + requiredIndicator: true, + CSSStyles: { + ...styles.LABEL_CSS, + 'margin': '4px 0 14px 45px' + } + }).component(); + this._networkPathText = _view.modelBuilder.inputBox() + .withProps({ + value: '', + width: '300px', + placeHolder: constants.TDE_WIZARD_CERTS_NETWORK_SHARE_PLACEHOLDER, + required: true, + CSSStyles: { ...styles.BODY_CSS, 'margin-top': '-1em', 'margin-left': '45px' } + }).component(); + + this._adsConfirmationCheckBox = _view.modelBuilder.checkBox() + .withProps({ + label: constants.TDE_WIZARD_MIGRATION_OPTION_ADS_CONFIRM, + checked: this.migrationStateModel.tdeMigrationConfig.isTdeMigrationMethodAdsConfirmed(), + CSSStyles: { + ...styles.BODY_CSS, + 'margin': '10px 0 14px 15px' + } + }).component(); + this._disposables.push( + this._adsConfirmationCheckBox.onChanged(async checked => { + this.migrationStateModel.tdeMigrationConfig.setAdsConfirmation( + checked, + this._networkPathText.value ?? ''); + await this.updateUI(); + })); + + container.addItems([ + adsMethodInfoMessage, + networkPathLabel, + this._networkPathText, + this._adsConfirmationCheckBox]); + + return container; + } + + private createManualWarningContainer(_view: azdata.ModelView): azdata.FlexContainer { + const container = _view.modelBuilder.flexContainer().withProps({ + CSSStyles: { + 'flex-direction': 'column' + } + }).component(); + + const manualMethodConfirmationDialog = _view.modelBuilder.infoBox() + .withProps({ + text: constants.TDE_WIZARD_MIGRATION_OPTION_MANUAL_WARNING, + style: 'warning', + CSSStyles: { + ...styles.BODY_CSS, + 'margin': '4px 14px 0px 14px' + }, + links: [{ + text: constants.LEARN_MORE, + url: 'https://learn.microsoft.com/azure/azure-sql/managed-instance/tde-certificate-migrate', + accessibilityInformation: { + label: constants.LEARN_MORE + } + }] + }).component(); + + container.addItems([ + manualMethodConfirmationDialog]); + return container; + } + + private async updateUI(): Promise { + const useAds = this.migrationStateModel.tdeMigrationConfig.isTdeMigrationMethodAds(); + + this._networkPathText.value = this.migrationStateModel.tdeMigrationConfig._networkPath; + this._adsConfirmationCheckBox.checked = this.migrationStateModel.tdeMigrationConfig.isTdeMigrationMethodAdsConfirmed(); + await utils.updateControlDisplay(this._adsMethodConfirmationContainer, useAds); + + const useManual = this.migrationStateModel.tdeMigrationConfig.isTdeMigrationMethodManual(); + await utils.updateControlDisplay(this._manualMethodWarningContainer, useManual); + + this.dialog!.okButton.enabled = this.migrationStateModel.tdeMigrationConfig.isTdeMigrationMethodSet(); + + this._networkPathText.required = useAds; + } + + public async openDialog(dialogName?: string,) { + if (!this._isOpen) { + + this.migrationStateModel.tdeMigrationConfig.configurationShown(); + + this._isOpen = true; + this.dialog = azdata.window.createModelViewDialog( + constants.TDE_WIZARD_TITLE, + 'TdeConfigurationDialog', + 'narrow'); + + this.dialog.okButton.label = constants.APPLY; + this._disposables.push( + this.dialog.okButton.onClick( + (eventArgs) => { + this._isOpen = false; + this.migrationStateModel.tdeMigrationConfig.setConfigurationCompleted(); + + if (this.migrationStateModel.tdeMigrationConfig.shouldAdsMigrateCertificates()) { + this.migrationStateModel.tdeMigrationConfig._networkPath = this._networkPathText.value ?? ''; + } + this._onClosed(); + }) + ); + + this._disposables.push( + this.dialog.cancelButton.onClick( + () => { + this._isOpen = false; + this._onClosed(); + })); + + const promise = this.initializeDialog(this.dialog); + azdata.window.openDialog(this.dialog); + await promise; + + await this.updateUI(); + } + } + + public get isOpen(): boolean { + return this._isOpen; + } +} diff --git a/extensions/sql-migration/src/dialog/tdeConfiguration/tdeMigrationDialog.ts b/extensions/sql-migration/src/dialog/tdeConfiguration/tdeMigrationDialog.ts new file mode 100644 index 0000000000..71aeb9acc6 --- /dev/null +++ b/extensions/sql-migration/src/dialog/tdeConfiguration/tdeMigrationDialog.ts @@ -0,0 +1,528 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. 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 { logError, TelemetryErrorName, TelemetryViews } from '../../telemtery'; +import { EOL } from 'os'; +import { MigrationStateModel, OperationResult } from '../../models/stateMachine'; +import { IconPathHelper } from '../../constants/iconPathHelper'; +import { TdeMigrationState, TdeMigrationResult, TdeMigrationDbState, TdeDatabaseMigrationState, TdeMigrationDbResult } from '../../models/tdeModels'; + +const DialogName = 'TdeMigrationDialog'; + +export enum TdeValidationResultIndex { + name = 0, + icon = 1, + status = 2, + errors = 3, + state = 4, + updated = 5 +} + +export const ValidationStatusLookup: constants.LookupTable = { + [TdeMigrationState.Canceled]: constants.STATE_CANCELED, + [TdeMigrationState.Failed]: constants.STATE_FAILED, + [TdeMigrationState.Pending]: constants.STATE_PENDING, + [TdeMigrationState.Running]: constants.STATE_RUNNING, + [TdeMigrationState.Succeeded]: constants.STATE_SUCCEEDED, + default: undefined +}; + + +export class TdeMigrationDialog { + + //private _canceled: boolean = true; + private _dialog: azdata.window.Dialog | undefined; + private _disposables: vscode.Disposable[] = []; + private _isOpen: boolean = false; + private _model!: MigrationStateModel; + private _resultsTable!: azdata.TableComponent; + private _startMigrationLoader!: azdata.LoadingComponent; + private _retryMigrationButton!: azdata.ButtonComponent; + private _copyButton!: azdata.ButtonComponent; + private _headingText!: azdata.TextComponent; + private _progressReportText!: azdata.TextComponent; + private _validationResult: any[][] = []; + private _dbRowsMap: Map = new Map(); + private _tdeMigrationResult: TdeMigrationResult = { + state: TdeMigrationState.Pending, + dbList: [] + }; + private _valdiationErrors: string[] = []; + private _completedDatabasesCount: number = 0; + + constructor( + model: MigrationStateModel) { + this._model = model; + } + + public async openDialog(): Promise { + if (!this._isOpen) { + this._isOpen = true; + this._dialog = azdata.window.createModelViewDialog( + constants.TDE_MIGRATEDIALOG_TITLE, + DialogName, + 600); + + const promise = this._initializeDialog(this._dialog); + azdata.window.openDialog(this._dialog); + await promise; + + await this._loadMigrationResults(); + + // This will prevent that it tryes to auto run when the last execution was successful, fails don't get persisted on the ui, only reported in the events. + if (this._tdeMigrationResult.state === TdeMigrationState.Pending) { + await this._runTdeMigration(); + } + } + } + + private async _initializeDialog(dialog: azdata.window.Dialog): Promise { + return new Promise((resolve, reject) => { + dialog.registerContent(async (view) => { + try { + dialog.okButton.label = constants.TDE_MIGRATE_DONE_BUTTON; + dialog.okButton.position = 'left'; + dialog.okButton.enabled = false; + dialog.cancelButton.position = 'left'; + + this._headingText = view.modelBuilder.text() + .withProps({ + value: constants.TDE_MIGRATE_HEADING, + CSSStyles: { + 'font-size': '13px', + 'font-weight': '400', + 'margin-bottom': '10px', + }, + }) + .component(); + this._startMigrationLoader = view.modelBuilder.loadingComponent() + .withProps({ + loading: false, + CSSStyles: { 'margin': '5px 0 0 10px' } + }) + .component(); + this._progressReportText = view.modelBuilder.text() + .withProps({ + value: '', + CSSStyles: { + 'font-size': '13px', + 'font-weight': '400', + 'margin-bottom': '10px', + 'margin-left': '5px' + }, + }) + .component(); + + const headingContainer = view.modelBuilder.flexContainer() + .withLayout({ + flexFlow: 'row', + justifyContent: 'flex-start', + }) + .withItems([this._headingText, this._progressReportText, this._startMigrationLoader], { flex: '0 0 auto' }) + .component(); + + this._resultsTable = await this._createResultsTable(view); + + this._retryMigrationButton = view.modelBuilder.button() + .withProps({ + iconPath: IconPathHelper.restartDataCollection, + iconHeight: 18, + iconWidth: 18, + width: 100, + label: constants.TDE_MIGRATE_RETRY_VALIDATION, + }).component(); + this._copyButton = view.modelBuilder.button() + .withProps({ + iconPath: IconPathHelper.copy, + iconHeight: 18, + iconWidth: 18, + width: 150, + label: constants.TDE_MIGRATE_COPY_RESULTS, + enabled: false, + }).component(); + + this._disposables.push( + this._retryMigrationButton.onDidClick( + async (e) => await this._retryTdeMigration())); + + this._disposables.push( + this._copyButton.onDidClick( + async (e) => this._copyValidationResults())); + + const toolbar = view.modelBuilder.toolbarContainer() + .withToolbarItems([ + { component: this._retryMigrationButton } + //{ component: this._copyButton } + ]) + .component(); + + const resultsHeading = view.modelBuilder.text() + .withProps({ + value: constants.TDE_MIGRATE_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 async _loadMigrationResults(): Promise { + const tdeMigrationResult = this._model.tdeMigrationConfig.lastTdeMigrationResult(); + this._progressReportText.value = ''; + + if (tdeMigrationResult.state === TdeMigrationState.Pending) { + //First time it is called. Should auto start. + this._headingText.value = constants.TDE_MIGRATE_RESULTS_HEADING; + + //Initialize results using the tde enabled databases; + tdeMigrationResult.dbList = this._model.tdeMigrationConfig.getTdeEnabledDatabases().map( + db => ({ + name: db, + dbState: TdeDatabaseMigrationState.Running, + message: '' + } + )); + + this._startMigrationLoader.loading = true; + this._retryMigrationButton.enabled = false; + this._copyButton.enabled = false; + this._dialog!.okButton.enabled = false; + this._dialog!.cancelButton.enabled = true; + } else { + //It already ran. Just load the previous status. + this._headingText.value = constants.TDE_MIGRATE_RESULTS_HEADING_PREVIOUS; + this._startMigrationLoader.loading = false; + this._retryMigrationButton.enabled = true; + this._copyButton.enabled = true; + this._dialog!.okButton.enabled = true; + this._dialog!.cancelButton.enabled = true; + } + + //Grab copy of data with a different result reference. Done here because it is closer to the assigment on the true path. + this._tdeMigrationResult = { + state: tdeMigrationResult.state, + dbList: tdeMigrationResult.dbList + }; + + await this._populateTableResults(); + } + + private async _retryTdeMigration(): Promise { + const tdeMigrationResult = this._model.tdeMigrationConfig.lastTdeMigrationResult(); + tdeMigrationResult.dbList = this._model.tdeMigrationConfig.getTdeEnabledDatabases().map( + db => ({ + name: db, + dbState: TdeDatabaseMigrationState.Running, + message: '' + } + )); + + this._tdeMigrationResult = { + state: tdeMigrationResult.state, + dbList: tdeMigrationResult.dbList + }; + + await this._populateTableResults(); + + await this._runTdeMigration(); + } + + private _updateProgressText(): void { + this._progressReportText.value = constants.TDE_COMPLETED_STATUS(this._completedDatabasesCount, this._model.tdeMigrationConfig.getTdeEnabledDatabasesCount()); + } + + private async _runTdeMigration(): Promise { + //Update the UI buttons + this._headingText.value = constants.TDE_MIGRATE_RESULTS_HEADING; + this._startMigrationLoader.loading = true; + this._retryMigrationButton.enabled = false; + this._copyButton.enabled = false; + this._dialog!.okButton.enabled = false; + this._dialog!.cancelButton.enabled = true; + + + //Send the external command + try { + this._completedDatabasesCount = 0; + this._updateProgressText(); + + //Get access token + const accessToken = await azdata.accounts.getAccountSecurityToken(this._model._azureAccount, this._model._azureAccount.properties.tenants[0].id, azdata.AzureResource.ResourceManagement); + + const operationResult = await this._model.startTdeMigration(accessToken!.token, this._updateTableResultRow.bind(this)); + + await this._updateTableFromOperationResult(operationResult); + + if (operationResult.success) { + this._dialog!.okButton.enabled = true; + + this._tdeMigrationResult = { + state: TdeMigrationState.Succeeded, + dbList: operationResult.result.map( + db => ({ + name: db.name, + dbState: TdeDatabaseMigrationState.Succeeded, + message: db.message + } + )) + }; + + this._model.tdeMigrationConfig.setTdeMigrationResult(this._tdeMigrationResult); // Set value on success. + } + else { + this._dialog!.okButton.enabled = false; + const errorDetails = operationResult.errors.join(EOL); + + logError(TelemetryViews.MigrationLocalStorage, TelemetryErrorName.StartMigrationFailed, errorDetails); + } + + this._startMigrationLoader.loading = false; + this._retryMigrationButton.enabled = true; + this._copyButton.enabled = true; + + this._completedDatabasesCount = this._model.tdeMigrationConfig.getTdeEnabledDatabasesCount(); //Force the total to match + this._updateProgressText(); + + } catch (error) { + //Catch any exception and failed any pending table. + this._startMigrationLoader.loading = false; + this._retryMigrationButton.enabled = true; + this._copyButton.enabled = false; + this._dialog!.okButton.enabled = false; + this._progressReportText.value = ''; + } + + this._headingText.value = constants.TDE_MIGRATE_RESULTS_HEADING_COMPLETED; + } + + private async _copyValidationResults(): Promise { + const errorsText = this._valdiationErrors.join(EOL); + const msg = errorsText.length === 0 + ? constants.TDE_MIGRATE_VALIDATION_COMPLETED + : constants.TDE_MIGRATE_VALIDATION_COMPLETED_ERRORS(errorsText); + return vscode.env.clipboard.writeText(msg); + } + + 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[TdeValidationResultIndex.status]; + const errors = results[TdeValidationResultIndex.errors]; + statusMessages.push( + constants.TDE_MIGRATE_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: constants.TDE_MIGRATE_COLUMN_DATABASES, + 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: constants.TDE_MIGRATE_COLUMN_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 async _updateTableFromOperationResult(operationResult: OperationResult): Promise { + let anyRowUpdated = false; + + operationResult.result.forEach((element) => { + const rowResultsIndex = this._dbRowsMap.get(element.name)!; //Checked already at the beginning of the method + const currentRow = this._validationResult[rowResultsIndex]; + + if (!currentRow[TdeValidationResultIndex.updated]) { + anyRowUpdated = true; + this._updateValidationResultRow(element.name, element.success, element.message); + } + }); + + if (anyRowUpdated) { + // Update the table + await this._updateTableData(); + } + } + + private async _updateTableResultRow(dbName: string, succeeded: boolean, message: string): Promise { + if (!this._dbRowsMap.has(dbName)) { + return; //Table not found + } + + this._updateValidationResultRow(dbName, succeeded, message); + + // Update the table + await this._updateTableData(); + + // When the updates come after the method finished. Thread related, out of our control. + if (this._completedDatabasesCount < this._model.tdeMigrationConfig.getTdeEnabledDatabasesCount()) { + this._completedDatabasesCount++; // Increase the completed count + this._updateProgressText(); + } + } + + private _updateValidationResultRow(dbName: string, succeeded: boolean, message: string) { + const rowResultsIndex = this._dbRowsMap.get(dbName)!; //Checked already at the beginning of the method + const tmpRow = this._buildRow({ + name: dbName, + dbState: (succeeded) ? TdeDatabaseMigrationState.Succeeded : TdeDatabaseMigrationState.Failed, + message: message + }, + true); + + // Update the local result + this._validationResult[rowResultsIndex] = tmpRow; + } + + private async _updateTableData() { + const data = this._validationResult.map(row => [ + row[TdeValidationResultIndex.name], + row[TdeValidationResultIndex.icon], + row[TdeValidationResultIndex.status]]); + + await this._resultsTable.updateProperty('data', data); + } + + private async _populateTableResults(): Promise { + //Create the local result from the model. + this._validationResult = this._tdeMigrationResult.dbList.map(db => this._buildRow(db)); + this._dbRowsMap = this._validationResult.reduce(function (map: Map, row: any[], currentIndex) { + const dbName = row[TdeValidationResultIndex.name]; + map.set(dbName, currentIndex); + return map; + }, new Map()); + + //Update the table. + await this._updateTableData(); + } + + private _buildRow(db: TdeMigrationDbState, updated: boolean = false): any[] { + + const statusMsg = ValidationStatusLookup[db.dbState]; + + const statusMessage = (db.dbState === TdeDatabaseMigrationState.Failed || db.dbState === TdeDatabaseMigrationState.Canceled) + ? constants.TDE_MIGRATE_STATUS_ERROR(db.dbState, db.message) + : statusMsg; + + const row: any[] = [ + db.name, + { + icon: this._getValidationStateImage(db.dbState), + title: statusMessage, + }, + ValidationStatusLookup[db.dbState], + db.message, + statusMsg, + updated + ]; + + return row; + } + + private _getValidationStateImage(state: TdeDatabaseMigrationState): azdata.IconPath { + switch (state) { + case TdeDatabaseMigrationState.Canceled: + return IconPathHelper.cancel; + case TdeDatabaseMigrationState.Failed: + return IconPathHelper.error; + case TdeDatabaseMigrationState.Running: + return IconPathHelper.inProgressMigration; + case TdeDatabaseMigrationState.Succeeded: + return IconPathHelper.completedMigration; + default: + return IconPathHelper.notStartedMigration; + } + } + + + +} diff --git a/extensions/sql-migration/src/models/stateMachine.ts b/extensions/sql-migration/src/models/stateMachine.ts index 83adf9e77c..cf8e5b12cf 100644 --- a/extensions/sql-migration/src/models/stateMachine.ts +++ b/extensions/sql-migration/src/models/stateMachine.ts @@ -16,6 +16,7 @@ import { hashString, deepClone } from '../api/utils'; import { SKURecommendationPage } from '../wizard/skuRecommendationPage'; import { excludeDatabases, getConnectionProfile, LoginTableInfo, SourceDatabaseInfo, TargetDatabaseInfo } from '../api/sqlUtils'; import { LoginMigrationModel, LoginMigrationStep } from './loginMigrationModel'; +import { TdeMigrationDbResult, TdeMigrationModel } from './tdeModels'; const localize = nls.loadMessageBundle(); export enum ValidateIrState { @@ -65,6 +66,10 @@ export enum MigrationSourceAuthenticationType { Sql = 'SqlAuthentication' } +export enum AssessmentRuleId { + TdeEnabled = 'TdeEnabled' +} + export enum MigrationMode { ONLINE, OFFLINE @@ -173,6 +178,7 @@ export interface SkuRecommendationSavedInfo { } export class MigrationStateModel implements Model, vscode.Disposable { + public _azureAccounts!: azdata.Account[]; public _azureAccount!: azdata.Account; public _accountTenants!: azurecore.Tenant[]; @@ -276,6 +282,8 @@ export class MigrationStateModel implements Model, vscode.Disposable { public _sessionId: string = uuidv4(); public serverName!: string; + public tdeMigrationConfig: TdeMigrationModel = new TdeMigrationModel(); + private _stateChangeEventEmitter = new vscode.EventEmitter(); private _currentState: State; private _gatheringInformationError: string | undefined; @@ -289,7 +297,8 @@ export class MigrationStateModel implements Model, vscode.Disposable { constructor( public extensionContext: vscode.ExtensionContext, private readonly _sourceConnectionId: string, - public readonly migrationService: mssql.ISqlMigrationService + public readonly migrationService: mssql.ISqlMigrationService, + public readonly tdeMigrationService: mssql.ITdeMigrationService ) { this._currentState = State.INIT; this._databaseBackup = {} as DatabaseBackupModel; @@ -300,6 +309,7 @@ export class MigrationStateModel implements Model, vscode.Disposable { this._assessmentReportFilePath = ''; this._skuRecommendationReportFilePaths = []; this.mementoString = 'sqlMigration.assessmentResults'; + this._targetManagedInstances = []; this._skuScalingFactor = 100; this._skuTargetPercentile = 95; @@ -1074,6 +1084,55 @@ export class MigrationStateModel implements Model, vscode.Disposable { ).map(t => t.name); } + public async startTdeMigration( + accessToken: string, + reportUpdate: (dbName: string, succeeded: boolean, message: string) => Promise): Promise> { + + const tdeEnabledDatabases = this.tdeMigrationConfig.getTdeEnabledDatabases(); + const connectionString = await azdata.connection.getConnectionString(this.sourceConnectionId, true); + + const opResult: OperationResult = { + success: false, + result: [], + errors: [] + }; + + try { + + const migrationResult = await this.tdeMigrationService.migrateCertificate( + tdeEnabledDatabases, + connectionString, + this._targetSubscription?.id, + this._resourceGroup?.name, + this._targetServerInstance.name, + this.tdeMigrationConfig._networkPath, + accessToken, + reportUpdate); + + opResult.errors = migrationResult.migrationStatuses + .filter(entry => !entry.success) + .map(entry => constants.TDE_MIGRATION_ERROR_DB(entry.dbName, entry.message)); + + opResult.result = migrationResult.migrationStatuses.map(m => ({ + name: m.dbName, + success: m.success, + message: m.message + })); + + } catch (e) { + opResult.errors = [constants.TDE_MIGRATION_ERROR(e.message)]; + + opResult.result = tdeEnabledDatabases.map(m => ({ + name: m, + success: false, + message: e.message + })); + } + + opResult.success = opResult.errors.length === 0; //Set success when there are no errors. + return opResult; + } + public async startMigration() { const sqlConnections = await azdata.connection.getConnections(); const currentConnection = sqlConnections.find( @@ -1329,7 +1388,6 @@ export class MigrationStateModel implements Model, vscode.Disposable { await this.extensionContext.globalState.update(`${this.mementoString}.${serverName}`, saveInfo); } } - public async loadSavedInfo(): Promise { try { this._targetType = this.savedInfo.migrationTargetType || undefined!; @@ -1391,6 +1449,8 @@ export class MigrationStateModel implements Model, vscode.Disposable { } catch { return false; } + + } public GetTargetType(): string { @@ -1425,3 +1485,9 @@ export interface SkuRecommendation { recommendations?: mssql.SkuRecommendationResult; recommendationError?: Error; } + +export interface OperationResult { + success: boolean; + result: T; + errors: string[]; +} diff --git a/extensions/sql-migration/src/models/tdeModels.ts b/extensions/sql-migration/src/models/tdeModels.ts new file mode 100644 index 0000000000..b38a7fbc23 --- /dev/null +++ b/extensions/sql-migration/src/models/tdeModels.ts @@ -0,0 +1,173 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export enum TdeMigrationState { + Pending = 'Pending', + Running = 'Running', + Succeeded = 'Succeeded', + Failed = 'Failed', + Canceled = 'Canceled', +} + +export enum TdeDatabaseMigrationState { + Running = 'Running', + Succeeded = 'Succeeded', + Failed = 'Failed', + Canceled = 'Canceled', +} + +export interface TdeMigrationDbState { + name: string; + dbState: TdeDatabaseMigrationState; + message: string; +} + +export interface TdeMigrationResult { + dbList: TdeMigrationDbState[]; + state: TdeMigrationState; +} + + +export interface TdeMigrationDbResult { + name: string; + success: boolean; + message: string; +} + + +export class TdeMigrationModel { + private _exportUsingADS?: boolean | undefined; + private _adsExportConfirmation: boolean; + private _configurationCompleted: boolean; + private _shownBefore: boolean; + private _encryptedDbs: string[]; + private _tdeMigrationCompleted: boolean; + private _tdeMigrationResult: TdeMigrationResult = { + state: TdeMigrationState.Pending, + dbList: [] + }; + + public _networkPath: string; + + constructor( + ) { + this._exportUsingADS = true; + this._adsExportConfirmation = false; + this._configurationCompleted = false; + this._shownBefore = false; + this._encryptedDbs = []; + + this._networkPath = ''; + this._tdeMigrationCompleted = false; + } + + // If the configuration dialog was shown already. + public shownBefore(): boolean { + return this._shownBefore; + } + + // If the configuration dialog was shown already. + public configurationShown(): void { + this._shownBefore = true; + } + + // The number of encrypted databaes + public getTdeEnabledDatabasesCount(): number { + return this._encryptedDbs.length; + } + + // Whether or not there are tde enabled databases + public hasTdeEnabledDatabases(): boolean { + return this.getTdeEnabledDatabasesCount() > 0; + } + + // The list of encrypted databaes + public getTdeEnabledDatabases(): string[] { + return this._encryptedDbs; + } + + // Sets the databases that are + public setTdeEnabledDatabasesCount(encryptedDbs: string[]): void { + this._encryptedDbs = encryptedDbs; + this._tdeMigrationCompleted = false; // Reset the migration status when databases change + this._shownBefore = false; // Reset the tde dialog showing status when databases change + } + + // Sets the certificate migration method + public setTdeMigrationMethod(useAds: boolean): void { + if (useAds) { + this._exportUsingADS = true; + } else { + this._exportUsingADS = false; + this._adsExportConfirmation = false; + } + this._tdeMigrationCompleted = false; + } + + // When a migration configuration was configured and accepted on the configuration blade. + public setConfigurationCompleted(): void { + this._configurationCompleted = true; + } + + // When ADS is configured to do the certificates migration + public shouldAdsMigrateCertificates(): boolean { + return this.hasTdeEnabledDatabases() && this._configurationCompleted && this.isTdeMigrationMethodAdsConfirmed(); + } + + // When any valid method is properly set. + public isTdeMigrationMethodSet(): boolean { + return this.isTdeMigrationMethodAdsConfirmed() || this.isTdeMigrationMethodManual(); + } + + // When Ads is selected as method. may still need confirmation. + public isTdeMigrationMethodAds(): boolean { + return this._exportUsingADS === true; + } + + // When ads migration method is confirmed + public isTdeMigrationMethodAdsConfirmed(): boolean { + return this.isTdeMigrationMethodAds() && this._adsExportConfirmation === true; + } + + // When manual method is selected + public isTdeMigrationMethodManual(): boolean { + return this._exportUsingADS === false; + } + + // When manual method is selected + public tdeMigrationCompleted(): boolean { + return this._tdeMigrationCompleted; + } + + // Get the value for the lastest tde migration result + public lastTdeMigrationResult(): TdeMigrationResult { + return this._tdeMigrationResult; + } + + // Set the value for the latest tde migration + public setTdeMigrationResult(result: TdeMigrationResult): void { + this._tdeMigrationResult = result; + this._tdeMigrationCompleted = result.state === TdeMigrationState.Succeeded; + } + + // Reset last tde migration result + public resetTdeMigrationResult() { + this._tdeMigrationResult = { + state: TdeMigrationState.Pending, + dbList: [] + }; + } + + // When the confirmation is set, for ADS certificate migration method + public setAdsConfirmation(status: boolean, networkPath: string): void { + if (status && this.isTdeMigrationMethodAds()) { + this._adsExportConfirmation = true; + + this._networkPath = networkPath; + } else { + this._adsExportConfirmation = false; + } + } +} diff --git a/extensions/sql-migration/src/telemtery.ts b/extensions/sql-migration/src/telemtery.ts index cfe0a65fa0..50dd267ebe 100644 --- a/extensions/sql-migration/src/telemtery.ts +++ b/extensions/sql-migration/src/telemtery.ts @@ -37,7 +37,8 @@ export enum TelemetryViews { SelectMigrationServiceDialog = 'SelectMigrationServiceDialog', Utils = 'Utils', LoginMigrationWizardController = 'LoginMigrationWizardController', - LoginMigrationWizard = 'LoginMigrationWizard' + LoginMigrationWizard = 'LoginMigrationWizard', + TdeConfigurationDialog = 'TdeConfigurationDialog', } export enum TelemetryAction { @@ -66,6 +67,10 @@ export enum TelemetryAction { GetDatabasesListFailed = 'GetDatabasesListFailed' } +export enum TelemetryErrorName { + StartMigrationFailed = 'StartMigrationFailed' +} + export function logError(telemetryView: TelemetryViews, err: string, error: any): void { console.log(error); TelemetryReporter.sendErrorEvent(telemetryView, err); diff --git a/extensions/sql-migration/src/wizard/skuRecommendationPage.ts b/extensions/sql-migration/src/wizard/skuRecommendationPage.ts index 9e7a98e2b7..cb15f0061a 100644 --- a/extensions/sql-migration/src/wizard/skuRecommendationPage.ts +++ b/extensions/sql-migration/src/wizard/skuRecommendationPage.ts @@ -8,7 +8,7 @@ import * as vscode from 'vscode'; import * as utils from '../api/utils'; import * as mssql from 'mssql'; import { MigrationWizardPage } from '../models/migrationWizardPage'; -import { MigrationStateModel, MigrationTargetType, PerformanceDataSourceOptions, StateChangeEvent } from '../models/stateMachine'; +import { MigrationStateModel, MigrationTargetType, PerformanceDataSourceOptions, StateChangeEvent, AssessmentRuleId } from '../models/stateMachine'; import { AssessmentResultsDialog } from '../dialog/assessmentResults/assessmentResultsDialog'; import { SkuRecommendationResultsDialog } from '../dialog/skuRecommendationResults/skuRecommendationResultsDialog'; import { GetAzureRecommendationDialog } from '../dialog/skuRecommendationResults/getAzureRecommendationDialog'; @@ -19,6 +19,9 @@ import { WIZARD_INPUT_COMPONENT_WIDTH } from './wizardController'; import * as styles from '../constants/styles'; import { SkuEditParametersDialog } from '../dialog/skuRecommendationResults/skuEditParametersDialog'; import { logError, TelemetryViews } from '../telemtery'; +import { TdeConfigurationDialog } from '../dialog/tdeConfiguration/tdeConfigurationDialog'; +import { TdeMigrationModel } from '../models/tdeModels'; +import * as os from 'os'; export interface Product { type: MigrationTargetType; @@ -46,6 +49,7 @@ export class SKURecommendationPage extends MigrationWizardPage { private _rootContainer!: azdata.FlexContainer; private _viewAssessmentsHelperText!: azdata.TextComponent; private _databaseSelectedHelperText!: azdata.TextComponent; + private _tdedatabaseSelectedHelperText!: azdata.TextComponent; private _azureRecommendationSectionText!: azdata.TextComponent; @@ -71,7 +75,10 @@ export class SKURecommendationPage extends MigrationWizardPage { private _skuEnableElasticRecommendationsText!: azdata.TextComponent; private assessmentGroupContainer!: azdata.FlexContainer; + private _tdeInfoContainer!: azdata.FlexContainer; private _disposables: vscode.Disposable[] = []; + private _tdeConfigurationDialog!: TdeConfigurationDialog; + private _previousMiTdeMigrationConfig: TdeMigrationModel = new TdeMigrationModel(); // avoid null checks private _serverName: string = ''; private _supportedProducts: Product[] = [ @@ -172,12 +179,14 @@ export class SKURecommendationPage extends MigrationWizardPage { this._chooseTargetComponent = await this.createChooseTargetComponent(view); const _azureRecommendationsContainer = this.createAzureRecommendationContainer(view); this.assessmentGroupContainer = await this.createViewAssessmentsContainer(); + this._tdeInfoContainer = await this.createTdeInfoContainer(); this._formContainer = view.modelBuilder.formContainer() .withFormItems([ { component: statusContainer, title: '' }, { component: this._chooseTargetComponent }, { component: _azureRecommendationsContainer }, - { component: this.assessmentGroupContainer }]) + { component: this.assessmentGroupContainer }, + { component: this._tdeInfoContainer }]) .withProps({ CSSStyles: { 'display': 'none', @@ -207,6 +216,7 @@ export class SKURecommendationPage extends MigrationWizardPage { await this._view.initializeModel(this._rootContainer); } + private createStatusComponent(view: azdata.ModelView): azdata.TextComponent { const component = view.modelBuilder.text() .withProps({ @@ -333,6 +343,7 @@ export class SKURecommendationPage extends MigrationWizardPage { if (value) { this.assessmentGroupContainer.display = 'inline'; this.changeTargetType(value.cardId); + await this.refreshTdeView(); } })); @@ -346,6 +357,40 @@ export class SKURecommendationPage extends MigrationWizardPage { return component; } + private async createTdeInfoContainer(): Promise { + const container = this._view.modelBuilder.flexContainer().withProps({ + CSSStyles: { + 'flex-direction': 'column' + } + }).component(); + + const editButton = this._view.modelBuilder.button().withProps({ + label: constants.TDE_BUTTON_CAPTION, + width: 180, + CSSStyles: { + ...styles.BODY_CSS, + 'margin': '0', + } + }).component(); + this._tdeConfigurationDialog = new TdeConfigurationDialog(this, this.wizard, this.migrationStateModel, () => this._onTdeConfigClosed()); + this._disposables.push(editButton.onDidClick( + async (e) => await this._tdeConfigurationDialog.openDialog())); + + this._tdedatabaseSelectedHelperText = this._view.modelBuilder.text() + .withProps({ + CSSStyles: { ...styles.BODY_CSS }, + ariaLive: 'polite', + }).component(); + + container.addItems([ + editButton, + this._tdedatabaseSelectedHelperText]); + + await utils.updateControlDisplay(container, false); + + return container; + } + private async createViewAssessmentsContainer(): Promise { this._viewAssessmentsHelperText = this._view.modelBuilder.text().withProps({ value: constants.SKU_RECOMMENDATION_VIEW_ASSESSMENT_MI, @@ -586,6 +631,7 @@ export class SKURecommendationPage extends MigrationWizardPage { }); await this.constructDetails(); this.wizard.nextButton.enabled = this.migrationStateModel._assessmentResults !== undefined; + this._previousMiTdeMigrationConfig = this.migrationStateModel.tdeMigrationConfig; } public async onPageLeave(pageChangeInfo: azdata.window.WizardPageChangeInfo): Promise { @@ -739,9 +785,77 @@ export class SKURecommendationPage extends MigrationWizardPage { this.changeTargetType(this._rbg.selectedCardId); } + await this.refreshTdeView(); + this._rbgLoader.loading = false; } + + private _resetTdeConfiguration() { + this._previousMiTdeMigrationConfig = this.migrationStateModel.tdeMigrationConfig; + this.migrationStateModel.tdeMigrationConfig = new TdeMigrationModel(); + } + + private async refreshTdeView() { + + if (this.migrationStateModel._targetType !== MigrationTargetType.SQLMI) { + + //Reset the encrypted databases counter on the model to ensure the certificates migration is ignored. + this._resetTdeConfiguration(); + + } else { + + const encryptedDbFound = this.migrationStateModel._assessmentResults.databaseAssessments + .filter( + db => this.migrationStateModel._databasesForMigration.findIndex(dba => dba === db.name) >= 0 && + db.issues.findIndex(iss => iss.ruleId === AssessmentRuleId.TdeEnabled && iss.appliesToMigrationTargetPlatform === MigrationTargetType.SQLMI) >= 0 + ) + .map(db => db.name); + + if (this._matchWithEncryptedDatabases(encryptedDbFound)) { + this.migrationStateModel.tdeMigrationConfig = this._previousMiTdeMigrationConfig; + } else { + if (os.platform() !== 'win32') //Only available for windows for now. + return; + + //Set encrypted databases + this.migrationStateModel.tdeMigrationConfig.setTdeEnabledDatabasesCount(encryptedDbFound); + + if (this.migrationStateModel.tdeMigrationConfig.hasTdeEnabledDatabases()) { + //Set the text when there are encrypted databases. + + if (!this.migrationStateModel.tdeMigrationConfig.shownBefore()) { + await this._tdeConfigurationDialog.openDialog(); + } + } else { + this._tdedatabaseSelectedHelperText.value = constants.TDE_WIZARD_MSG_EMPTY; + } + + } + } + + await utils.updateControlDisplay(this._tdeInfoContainer, this.migrationStateModel.tdeMigrationConfig.hasTdeEnabledDatabases()); + } + + private _onTdeConfigClosed() { + const tdeMsg = (this.migrationStateModel.tdeMigrationConfig.isTdeMigrationMethodAdsConfirmed()) ? constants.TDE_WIZARD_MSG_TDE : constants.TDE_WIZARD_MSG_MANUAL; + this._tdedatabaseSelectedHelperText.value = constants.TDE_MSG_DATABASES_SELECTED(this.migrationStateModel.tdeMigrationConfig.getTdeEnabledDatabasesCount(), tdeMsg); + } + + private _matchWithEncryptedDatabases(encryptedDbList: string[]): boolean { + var currentTdeDbs = this._previousMiTdeMigrationConfig.getTdeEnabledDatabases(); + + if (encryptedDbList.length === 0 || encryptedDbList.length !== currentTdeDbs.length) + return false; + + if (encryptedDbList.filter(db => currentTdeDbs.findIndex(dba => dba === db) < 0).length > 0) + return false; //There is at least one element that is not in the other array. There should be no risk of duplicates table names + + + return true; + } + + public async startCardLoading(): Promise { // TO-DO: ideally the short SKU recommendation loading time should have a spinning indicator, // but updating the card text will do for now diff --git a/extensions/sql-migration/src/wizard/summaryPage.ts b/extensions/sql-migration/src/wizard/summaryPage.ts index dfed248bc6..54adc0e3db 100644 --- a/extensions/sql-migration/src/wizard/summaryPage.ts +++ b/extensions/sql-migration/src/wizard/summaryPage.ts @@ -163,6 +163,11 @@ export class SummaryPage extends MigrationWizardPage { constants.SHIR, this.migrationStateModel._nodeNames.join(', '))); } + + this.wizard.registerNavigationValidator(async (pageChangeInfo) => { + this.wizard.message = { text: '' }; + return true; + }); } public async onPageLeave(pageChangeInfo: azdata.window.WizardPageChangeInfo): Promise { diff --git a/extensions/sql-migration/src/wizard/targetSelectionPage.ts b/extensions/sql-migration/src/wizard/targetSelectionPage.ts index 2a75daf53b..7ba6a149df 100644 --- a/extensions/sql-migration/src/wizard/targetSelectionPage.ts +++ b/extensions/sql-migration/src/wizard/targetSelectionPage.ts @@ -16,6 +16,9 @@ import { azureResource } from 'azurecore'; import { AzureSqlDatabaseServer, getVMInstanceView, SqlVMServer } from '../api/azure'; import { collectTargetDatabaseInfo, TargetDatabaseInfo } from '../api/sqlUtils'; import { MigrationLocalStorage, MigrationServiceContext } from '../models/migrationLocalStorage'; +import { TdeMigrationDialog } from '../dialog/tdeConfiguration/tdeMigrationDialog'; + +const TDE_MIGRATION_BUTTON_INDEX = 1; export class TargetSelectionPage extends MigrationWizardPage { private _view!: azdata.ModelView; @@ -81,10 +84,49 @@ export class TargetSelectionPage extends MigrationWizardPage { await this.populateAzureAccountsDropdown(); } + this._disposables.push( + this.wizard.customButtons[TDE_MIGRATION_BUTTON_INDEX].onClick( + async e => await this._startTdeMigration())); + await this._view.initializeModel(form); } + private async _startTdeMigration(): Promise { + const dialog = new TdeMigrationDialog(this.migrationStateModel); + + await dialog.openDialog(); + } + public async onPageEnter(pageChangeInfo: azdata.window.WizardPageChangeInfo): Promise { + this.wizard.customButtons[TDE_MIGRATION_BUTTON_INDEX].hidden = !this.migrationStateModel.tdeMigrationConfig.shouldAdsMigrateCertificates(); + this._updateTdeMigrationButtonStatus(); + + if (pageChangeInfo.newPage < pageChangeInfo.lastPage) { + return; + } + switch (this.migrationStateModel._targetType) { + case MigrationTargetType.SQLMI: + this._pageDescription.value = constants.AZURE_SQL_TARGET_PAGE_DESCRIPTION(constants.SKU_RECOMMENDATION_MI_CARD_TEXT); + this._azureResourceDropdownLabel.value = constants.AZURE_SQL_DATABASE_MANAGED_INSTANCE; + this._azureResourceDropdown.ariaLabel = constants.AZURE_SQL_DATABASE_MANAGED_INSTANCE; + break; + case MigrationTargetType.SQLVM: + this._pageDescription.value = constants.AZURE_SQL_TARGET_PAGE_DESCRIPTION(constants.SKU_RECOMMENDATION_VM_CARD_TEXT); + this._azureResourceDropdownLabel.value = constants.AZURE_SQL_DATABASE_VIRTUAL_MACHINE; + this._azureResourceDropdown.ariaLabel = constants.AZURE_SQL_DATABASE_VIRTUAL_MACHINE; + break; + case MigrationTargetType.SQLDB: + this._pageDescription.value = constants.AZURE_SQL_TARGET_PAGE_DESCRIPTION(constants.SKU_RECOMMENDATION_SQLDB_CARD_TEXT); + this._azureResourceDropdownLabel.value = constants.AZURE_SQL_DATABASE; + this._azureResourceDropdown.ariaLabel = constants.AZURE_SQL_DATABASE; + this._updateConnectionButtonState(); + if (this.migrationStateModel._didUpdateDatabasesForMigration) { + await this._resetTargetMapping(); + this.migrationStateModel._didUpdateDatabasesForMigration = false; + } + break; + } + this.wizard.registerNavigationValidator((pageChangeInfo) => { this.wizard.message = { text: '' }; if (pageChangeInfo.newPage < pageChangeInfo.lastPage) { @@ -174,6 +216,7 @@ export class TargetSelectionPage extends MigrationWizardPage { }; return false; } + return true; }); @@ -240,6 +283,8 @@ export class TargetSelectionPage extends MigrationWizardPage { public async onPageLeave(pageChangeInfo: azdata.window.WizardPageChangeInfo): Promise { this.wizard.registerNavigationValidator(pageChangeInfo => true); this.wizard.message = { text: '' }; + + this.wizard.customButtons[TDE_MIGRATION_BUTTON_INDEX].hidden = true; } protected async handleStateChange(e: StateChangeEvent): Promise { @@ -701,6 +746,9 @@ export class TargetSelectionPage extends MigrationWizardPage { }; } } + + this.migrationStateModel.tdeMigrationConfig.resetTdeMigrationResult(); + break; case MigrationTargetType.SQLDB: const sqlDatabaseServer = this.migrationStateModel._targetSqlDatabaseServers?.find( @@ -975,6 +1023,8 @@ export class TargetSelectionPage extends MigrationWizardPage { this.migrationStateModel._targetManagedInstances, this.migrationStateModel._location, this.migrationStateModel._resourceGroup); + + this._updateTdeMigrationButtonStatus(); break; case MigrationTargetType.SQLVM: this._azureResourceDropdown.values = await utils.getVirtualMachinesDropdownValues( @@ -1014,6 +1064,12 @@ export class TargetSelectionPage extends MigrationWizardPage { } } + private _updateTdeMigrationButtonStatus() { + + this.wizard.customButtons[TDE_MIGRATION_BUTTON_INDEX].enabled = this.migrationStateModel.tdeMigrationConfig.shouldAdsMigrateCertificates() && + this.migrationStateModel._targetManagedInstances.length > 0; + } + private async _populateResourceMappingTable(targetDatabases: TargetDatabaseInfo[]): Promise { // populate target database list const databaseValues = this._getTargetDatabaseDropdownValues( diff --git a/extensions/sql-migration/src/wizard/wizardController.ts b/extensions/sql-migration/src/wizard/wizardController.ts index eeb77e27ad..91bf3760df 100644 --- a/extensions/sql-migration/src/wizard/wizardController.ts +++ b/extensions/sql-migration/src/wizard/wizardController.ts @@ -79,7 +79,13 @@ export class WizardController { validateButton.secondary = false; validateButton.hidden = true; - this._wizardObject.customButtons = [validateButton, saveAndCloseButton]; + const tdeMigrateButton = azdata.window.createButton( + loc.TDE_MIGRATE_BUTTON, + 'left'); + tdeMigrateButton.secondary = false; + tdeMigrateButton.hidden = true; + + this._wizardObject.customButtons = [validateButton, tdeMigrateButton, saveAndCloseButton]; const databaseSelectorPage = new DatabaseSelectorPage(this._wizardObject, stateModel); const skuRecommendationPage = new SKURecommendationPage(this._wizardObject, stateModel); const targetSelectionPage = new TargetSelectionPage(this._wizardObject, stateModel);