diff --git a/extensions/sql-migration/DEVELOPER_GUIDE.md b/extensions/sql-migration/DEVELOPER_GUIDE.md index 5f4b4a1bbd38..56ecffe75a22 100644 --- a/extensions/sql-migration/DEVELOPER_GUIDE.md +++ b/extensions/sql-migration/DEVELOPER_GUIDE.md @@ -32,6 +32,8 @@ For example: ## Debugging the extension service: +To debug NuGet calls, once the migration service has been launched, navigate to VSCode > Run and Debug > select ".NET Core Attach" > attach to "MicrosoftSqlToolsMigration.exe". Breakpoints should then work as intended. + ### The logs for the extension and service during runtime can be accessed by: 1. Opening the command palette (Ctrl+P) and searching for "Developer: Open Extensions Logs Folder" diff --git a/extensions/sql-migration/src/api/sqlUtils.ts b/extensions/sql-migration/src/api/sqlUtils.ts index 8a9597ac9040..cdd7d0e3461e 100644 --- a/extensions/sql-migration/src/api/sqlUtils.ts +++ b/extensions/sql-migration/src/api/sqlUtils.ts @@ -9,6 +9,7 @@ import { AzureSqlDatabase, AzureSqlDatabaseServer } from './azure'; import { generateGuid } from './utils'; import * as utils from '../api/utils'; import { TelemetryAction, TelemetryViews, logError } from '../telemetry'; +import { DatabaseCollationMapping } from '../service/contracts'; import * as constants from '../constants/strings'; const query_database_tables_sql = ` @@ -90,6 +91,16 @@ const query_login_tables_include_windows_auth_sql = ` const query_is_sys_admin_sql = `SELECT IS_SRVROLEMEMBER('sysadmin');`; +const query_get_server_collation_sql = `SELECT SERVERPROPERTY('collation');`; + +const query_get_database_collations_sql = ` + SELECT + db.name as database_name, + db.collation_name as database_collation + FROM sys.databases db + WHERE db.name not in ('master', 'tempdb', 'model', 'msdb') + AND is_distributor <> 1;`; + export const excludeDatabases: string[] = [ 'master', 'tempdb', @@ -509,3 +520,39 @@ export async function isSourceConnectionSysAdmin(): Promise { return getSqlBoolean(results.rows[0][0]); } + +export async function getSourceServerLevelCollation(): Promise { + const sourceConnectionId = await getSourceConnectionId(); + const ownerUri = await azdata.connection.getUriForConnection(sourceConnectionId); + const queryProvider = azdata.dataprotocol.getProvider( + 'MSSQL', + azdata.DataProviderType.QueryProvider); + + const results = await queryProvider.runQueryAndReturn( + ownerUri, + query_get_server_collation_sql); + + return getSqlString(results.rows[0][0]); +} + +export async function getSourceDatabaseLevelCollations(): Promise { + const sourceConnectionId = await getSourceConnectionId(); + const ownerUri = await azdata.connection.getUriForConnection(sourceConnectionId); + const queryProvider = azdata.dataprotocol.getProvider( + 'MSSQL', + azdata.DataProviderType.QueryProvider); + + const results = await queryProvider.runQueryAndReturn( + ownerUri, + query_get_database_collations_sql); + + const databaseNames = results.rows.map(row => getSqlString(row[0])) ?? []; + const collations = results.rows.map(row => getSqlString(row[1])) ?? []; + + return databaseNames.map((databaseName, index) => { + return { + databaseName: databaseName, + databaseCollation: collations[index] + }; + }); +} diff --git a/extensions/sql-migration/src/constants/strings.ts b/extensions/sql-migration/src/constants/strings.ts index 0c3672341f81..8841fce3b4a2 100644 --- a/extensions/sql-migration/src/constants/strings.ts +++ b/extensions/sql-migration/src/constants/strings.ts @@ -310,6 +310,33 @@ export function TIME_IN_MINUTES(val: number): number { return val * 60000; } +// Target provisioning +export const TARGET_PROVISIONING_LINK = localize('sql.migration.targetprovisioning.link', "Deploy to Azure"); +export const TARGET_PROVISIONING_TITLE = localize('sql.migration.targetprovisioning.title', "Deployment script"); +export const TARGET_PROVISIONING_HEADER = localize('sql.migration.targetprovisioning.header', "Quickstart ARM template"); +export const TARGET_PROVISIONING_DESCRIPTION = localize('sql.migration.targetprovisioning.description', "If you don't already have an existing Azure SQL target, you can use this quickstart ARM template to help you provision one. An ARM (Azure Resource Manager) template defines Azure resources to deploy as well as their properties."); +export const TARGET_PROVISIONING_WARNING = localize('sql.migration.targetprovisioning.warning', "The provided template is a quickstart template. Review it, and after deployment, we recommend reviewing the {0} on how to solve common security requirements, and you should consult your database and security team on which features to implement."); +export const TARGET_PROVISIONING_BEST_PRACTICES = localize('sql.migration.targetprovisioning.bestpractices', "Best practices documentation"); +export const TARGET_PROVISIONING_MI_DETAILS = localize('sql.migration.targetprovisioning.mi', "This template will create an Azure SQL Managed Instance within a new virtual network. The recommended sizing configuration, including compute size, hardware family, service tier, storage size, and server collation has been prefilled."); +export const TARGET_PROVISIONING_VM_DETAILS = localize('sql.migration.targetprovisioning.vm', "This template will create a SQL Server on Azure Virtual Machine, which includes a new virtual machine, network interface, network security group, and public IP address. The recommended sizing configuration, including VM size, storage configuration (for data, log, and tempdb), and server collation has been prefilled."); +export function TARGET_PROVISIONING_DB_DETAILS(dbCount: number): string { + return dbCount === 1 + ? localize('sql.migration.targetprovisioning.db.one', "This template allows you to create a SQL Server and {0} SQL Database. The recommended sizing configuration has been prefilled.", dbCount) + : localize('sql.migration.targetprovisioning.db.many', "This template allows you to create a SQL Server and {0} SQL Databases. The recommended sizing configurations have already been prefilled.", dbCount); +} + +export const TARGET_PROVISIONING_HYPERLINK_LABEL = localize('sql.migration.targetprovisioning.hyperlink', "Learn more about ARM templates"); +export const TARGET_PROVISIONING_IN_PROGRESS = localize('sql.migration.targetprovisioning.inprogress', "Deployment script generation in progress..."); +export const TARGET_PROVISIONING_ERROR = localize('sql.migration.targetprovisioning.error', "An error occurred while generating the deployment script. Please try again."); +export const COPY_TO_CLIPBOARD = localize('sql.migration.copytoclipboard', "Copy to clipboard"); +export const COPIED_TO_CLIPBOARD = localize('sql.migration.copiedtoclipboard', "Copied to clipboard"); +export const TARGET_PROVISIONING_SAVE = localize('sql.migration.targetprovisioning.save', "Save template"); +export const TARGET_PROVISIONING_DEPLOY = localize('sql.migration.targetprovisioning.deploy', "Deploy in Azure Portal"); + +export function TARGET_PROVISIONING_SAVE_SUCCESS(filePath: string): string { + return localize('sql.migration.targetprovisioning.save.success', "Successfully saved provisioning script to {0}.", filePath); +} + // Login Migrations export function LOGIN_WIZARD_TITLE(instanceName: string): string { return localize('sql-migration.login.wizard.title', "Migrate logins from '{0}' to Azure SQL", instanceName); diff --git a/extensions/sql-migration/src/dialog/skuRecommendationResults/generateProvisioningScriptDialog.ts b/extensions/sql-migration/src/dialog/skuRecommendationResults/generateProvisioningScriptDialog.ts new file mode 100644 index 000000000000..2cc433bbbf0b --- /dev/null +++ b/extensions/sql-migration/src/dialog/skuRecommendationResults/generateProvisioningScriptDialog.ts @@ -0,0 +1,328 @@ +/*--------------------------------------------------------------------------------------------- + * 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 fs from 'fs'; +import { MigrationStateModel, MigrationTargetType } from '../../models/stateMachine'; +import { join } from 'path'; +import * as constants from '../../constants/strings'; +import * as contracts from '../../service/contracts'; +import * as styles from '../../constants/styles'; +import * as utils from '../../api/utils'; +import { logError, TelemetryViews } from '../../telemetry'; +import { IconPathHelper } from '../../constants/iconPathHelper'; + +export class GenerateProvisioningScriptDialog { + + private static readonly CloseButtonText: string = 'Close'; + + private dialog: azdata.window.Dialog | undefined; + private _isOpen: boolean = false; + + private _disposables: vscode.Disposable[] = []; + + private _generateArmTemplateContainer!: azdata.FlexContainer; + private _saveArmTemplateContainer!: azdata.FlexContainer; + private _armTemplateErrorContainer!: azdata.FlexContainer; + + private _armTemplateTextBox!: azdata.TextComponent; + private _armTemplateText!: string; + + constructor(public model: MigrationStateModel, public _targetType: MigrationTargetType) { } + + 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': '0px 16px', + 'flex-direction': 'column', + } + }).component(); + this._generateArmTemplateContainer = this.CreateGenerateArmTemplateContainer(_view); + this._saveArmTemplateContainer = this.CreateSaveArmTemplateContainer(_view); + this._armTemplateErrorContainer = this.CreateArmTemplateErrorContainer(_view); + container.addItems([ + this._generateArmTemplateContainer, + this._saveArmTemplateContainer, + this._armTemplateErrorContainer + ]); + return container; + } + + private CreateGenerateArmTemplateContainer(_view: azdata.ModelView): azdata.FlexContainer { + const armTemplateLoader = _view.modelBuilder.loadingComponent().component(); + const armTemplateProgress = _view.modelBuilder.text().withProps({ + value: constants.TARGET_PROVISIONING_IN_PROGRESS, + CSSStyles: { + ...styles.BODY_CSS, + 'margin-right': '20px' + } + }).component(); + + const armTemplateLoadingContainer = _view.modelBuilder.flexContainer().withLayout({ + height: '100%', + flexFlow: 'row', + }).component(); + + armTemplateLoadingContainer.addItem(armTemplateProgress, { flex: '0 0 auto' }); + armTemplateLoadingContainer.addItem(armTemplateLoader, { flex: '0 0 auto' }); + + // const armTemplateLoadingInfo = _view.modelBuilder.text().withProps({ + // // Replace with localized string in the future + // value: 'We are generating an ARM template according to your recommended SKU. This may take some time.', + // CSSStyles: { + // ...styles.BODY_CSS, + // } + // }).component(); + + // const armTemplateLoadingInfoCcontainer = _view.modelBuilder.flexContainer().withLayout({ + // height: '100%', + // flexFlow: 'row', + // }).component(); + + // armTemplateLoadingInfoCcontainer.addItem(armTemplateLoadingInfo, { flex: '0 0 auto' }); + + const container = _view.modelBuilder.flexContainer().withLayout({ + flexFlow: 'column' + }).withItems([ + armTemplateLoadingContainer, + // armTemplateLoadingInfoCcontainer + ]).component(); + + return container; + } + + private CreateSaveArmTemplateContainer(_view: azdata.ModelView): azdata.FlexContainer { + const armTemplateLabel = _view.modelBuilder.text() + .withProps({ + value: constants.TARGET_PROVISIONING_HEADER, + CSSStyles: { ...styles.LABEL_CSS, 'margin': '12px 0 0', } + }).component(); + + const armTemplateDescription = _view.modelBuilder.text().withProps({ + value: constants.TARGET_PROVISIONING_DESCRIPTION, + CSSStyles: { + ...styles.BODY_CSS, + 'margin-bottom': '8px', + } + }).component(); + + const armTemplateLearnMoreLink = _view.modelBuilder.hyperlink().withProps({ + position: 'absolute', + label: constants.TARGET_PROVISIONING_HYPERLINK_LABEL, + url: 'https://docs.microsoft.com/en-us/azure/azure-resource-manager/templates/quickstart-create-templates-use-the-portal#edit-and-deploy-the-template', + CSSStyles: { + ...styles.BODY_CSS, + 'margin-bottom': '24px' + }, + showLinkIcon: true, + }).component(); + + const armTemplateLearnMoreContainer = _view.modelBuilder.flexContainer().withItems([ + armTemplateLearnMoreLink, + ]).withProps({ + height: 20, + CSSStyles: { + 'margin-bottom': '8px', + } + }).component(); + + const armTemplateWarning = _view.modelBuilder.infoBox().withProps({ + text: constants.TARGET_PROVISIONING_WARNING, + style: 'information', + CSSStyles: { ...styles.BODY_CSS, }, + links: [{ + text: constants.TARGET_PROVISIONING_BEST_PRACTICES, + url: 'https://learn.microsoft.com/azure/azure-sql/database/security-best-practice', + }] + }).component(); + + const armTemplateSummary = _view.modelBuilder.text().withProps({ + value: this._targetType === MigrationTargetType.SQLMI + ? constants.TARGET_PROVISIONING_MI_DETAILS + : this._targetType === MigrationTargetType.SQLVM + ? constants.TARGET_PROVISIONING_VM_DETAILS + : constants.TARGET_PROVISIONING_DB_DETAILS(this.model._skuEnableElastic + ? this.model._skuRecommendationResults.recommendations?.sqlDbRecommendationResults.length! + : this.model._skuRecommendationResults.recommendations?.sqlDbRecommendationResults.length!), + CSSStyles: { + ...styles.BODY_CSS, + 'margin-bottom': '16px', + }, + }).component(); + + this._armTemplateTextBox = _view.modelBuilder.text().withProps({ + value: this._armTemplateText, + width: '100%', + height: '100%', + CSSStyles: { + 'font': '12px "Monaco", "Menlo", "Consolas", "Droid Sans Mono", "Inconsolata", "Courier New", monospace', + 'margin': '0', + 'padding': '8px', + 'white-space': 'pre', + 'background-color': '#eeeeee', + 'overflow-x': 'hidden', + 'word-break': 'break-all' + } + }).component(); + + const textContainer = _view.modelBuilder.flexContainer().withLayout({ + flexFlow: 'column', + height: 500, + }).withProps({ + CSSStyles: { + 'overflow': 'auto', + 'user-select': 'text', + 'margin-bottom': '8px', + } + }).withItems([ + this._armTemplateTextBox + ]).component(); + + const container = _view.modelBuilder.flexContainer().withLayout({ + flexFlow: 'column', + position: 'relative', + }).withProps({ + display: 'none', + }).withItems([ + armTemplateLabel, + armTemplateDescription, + armTemplateLearnMoreContainer, + armTemplateWarning, + armTemplateSummary, + textContainer, + ]).component(); + + return container; + } + + private CreateArmTemplateErrorContainer(_view: azdata.ModelView): azdata.FlexContainer { + + const errorIcon = _view.modelBuilder.image().withProps({ + iconPath: IconPathHelper.error, + iconHeight: 17, + iconWidth: 17, + width: 20, + height: 20 + }).component(); + + const errorText = _view.modelBuilder.text().withProps({ + value: constants.TARGET_PROVISIONING_ERROR, + CSSStyles: { + ...styles.BODY_CSS, + 'margin-left': '8px' + } + }).component(); + + const container = _view.modelBuilder.flexContainer().withProps({ + display: 'none', + }).component(); + + container.addItem(errorIcon, { flex: '0 0 auto' }); + container.addItem(errorText, { flex: '0 0 auto' }); + + return container; + } + + private async updateArmTemplateStatus(succeeded: boolean): Promise { + await this._generateArmTemplateContainer.updateCssStyles({ 'display': 'none' }); + + if (succeeded) { + this._armTemplateText = fs.readFileSync(this.model._provisioningScriptResult.result.provisioningScriptFilePath).toString(); + this._armTemplateTextBox.value = this._armTemplateText; + await this._saveArmTemplateContainer.updateCssStyles({ 'display': 'inline' }); + } + else { + await this._armTemplateErrorContainer.updateCssStyles({ 'display': 'inline' }); + } + } + + public async openDialog(dialogName?: string, recommendations?: contracts.SkuRecommendationResult) { + if (!this._isOpen) { + this._isOpen = true; + + this.dialog = azdata.window.createModelViewDialog(constants.TARGET_PROVISIONING_TITLE, 'ViewArmTemplateDialog', 'medium'); + + this.dialog.okButton.label = GenerateProvisioningScriptDialog.CloseButtonText; + this.dialog.okButton.position = 'left'; + this._disposables.push(this.dialog.okButton.onClick(async () => await this.execute())); + this.dialog.cancelButton.hidden = true; + + const armTemplateSaveButton = azdata.window.createButton(constants.TARGET_PROVISIONING_SAVE, 'right'); + this._disposables.push(armTemplateSaveButton.onClick(async () => await this.saveArmTemplate())); + + const armTemplateCopyButton = azdata.window.createButton(constants.COPY_TO_CLIPBOARD, 'right'); + this._disposables.push(armTemplateCopyButton.onClick(async () => await this.copyArmTemplate())); + + const armTemplatePortalButton = azdata.window.createButton(constants.TARGET_PROVISIONING_DEPLOY, 'right'); + this._disposables.push(armTemplatePortalButton.onClick(() => vscode.env.openExternal(vscode.Uri.parse('https://portal.azure.com/#create/Microsoft.Template')))); + + this.dialog.customButtons = [armTemplateSaveButton, armTemplateCopyButton, armTemplatePortalButton]; + + const dialogSetupPromises: Thenable[] = []; + dialogSetupPromises.push(this.initializeDialog(this.dialog)); + + azdata.window.openDialog(this.dialog); + await Promise.all(dialogSetupPromises); + + // Generate ARM template upon opening dialog + await this.model.generateProvisioningScript(this._targetType); + const error = this.model._provisioningScriptResult.error; + + if (error) { + logError(TelemetryViews.ProvisioningScriptWizard, 'ProvisioningScriptGenerationUnexpectedError', error); + await this.updateArmTemplateStatus(false); + } + else { + await this.updateArmTemplateStatus(true); + } + } + } + + protected async execute() { + this._isOpen = false; + } + + public get isOpen(): boolean { + return this._isOpen; + } + + private async saveArmTemplate(): Promise { + const filePath = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(join(utils.getUserHome()!, 'ARMTemplate-' + this._targetType + '-' + new Date().toISOString().split('T')[0] + '.json')), + filters: { + 'JSON File': ['json'] + } + }); + + fs.writeFileSync(filePath!.fsPath, this._armTemplateText); + void vscode.window.showInformationMessage(constants.TARGET_PROVISIONING_SAVE_SUCCESS(filePath?.path!)); + } + + private async copyArmTemplate(): Promise { + await vscode.env.clipboard.writeText(this._armTemplateText); + void vscode.window.showInformationMessage(constants.COPIED_TO_CLIPBOARD); + } +} diff --git a/extensions/sql-migration/src/models/stateMachine.ts b/extensions/sql-migration/src/models/stateMachine.ts index 29d5f2e59c3a..7a53e624d147 100644 --- a/extensions/sql-migration/src/models/stateMachine.ts +++ b/extensions/sql-migration/src/models/stateMachine.ts @@ -15,7 +15,7 @@ import { v4 as uuidv4 } from 'uuid'; import { sendSqlMigrationActionEvent, TelemetryAction, TelemetryViews, logError } from '../telemetry'; import { hashString, deepClone, getBlobContainerNameWithFolder, Blob, getLastBackupFileNameWithoutFolder } from '../api/utils'; import { SKURecommendationPage } from '../wizard/skuRecommendationPage'; -import { excludeDatabases, getEncryptConnectionValue, getSourceConnectionId, getSourceConnectionProfile, getSourceConnectionServerInfo, getSourceConnectionString, getSourceConnectionUri, getTrustServerCertificateValue, SourceDatabaseInfo, TargetDatabaseInfo } from '../api/sqlUtils'; +import { excludeDatabases, getEncryptConnectionValue, getSourceConnectionId, getSourceConnectionProfile, getSourceConnectionServerInfo, getSourceConnectionString, getSourceConnectionUri, getSourceDatabaseLevelCollations, getSourceServerLevelCollation, getTrustServerCertificateValue, SourceDatabaseInfo, TargetDatabaseInfo } from '../api/sqlUtils'; import { LoginMigrationModel } from './loginMigrationModel'; import { TdeMigrationDbResult, TdeMigrationModel } from './tdeModels'; import { NetworkInterfaceModel } from '../api/dataModels/azure/networkInterfaceModel'; @@ -238,7 +238,8 @@ export class MigrationStateModel implements Model, vscode.Disposable { private _skuRecommendationApiResponse!: contracts.SkuRecommendationResult; public _skuRecommendationReportFilePaths: string[]; public _skuRecommendationPerformanceLocation!: string; - + public _provisioningScriptApiResponse!: contracts.ProvisioningScriptResult; + public _provisioningScriptResult!: ProvisioningScript; public _perfDataCollectionStartDate!: Date | undefined; public _perfDataCollectionStopDate!: Date | undefined; public _perfDataCollectionLastRefreshedDate!: Date; @@ -627,6 +628,55 @@ export class MigrationStateModel implements Model, vscode.Disposable { } } + public async generateProvisioningScript(targetType: MigrationTargetType): Promise { + try { + const serverLevelCollation = await getSourceServerLevelCollation(); + + const recommendedDatabases: string[] = this._skuRecommendationResults.recommendations!.sqlDbRecommendationResults.map(result => result.databaseName); + const databaseLevelCollations = (await getSourceDatabaseLevelCollations()).filter(db => recommendedDatabases.includes(db.databaseName)); + + let recommendations: contracts.SkuRecommendationResultItem[]; + switch (targetType) { + case MigrationTargetType.SQLVM: { + // to-do: SQL VM collation + recommendations = this._skuRecommendationResults.recommendations!.sqlVmRecommendationResults; + break; + } + case MigrationTargetType.SQLDB: { + recommendations = this._skuRecommendationResults.recommendations!.sqlDbRecommendationResults; + break; + } + case MigrationTargetType.SQLMI: { + recommendations = this._skuRecommendationResults.recommendations!.sqlMiRecommendationResults; + break; + } + } + + const response = (await this.migrationService.generateProvisioningScript( + recommendations, + serverLevelCollation, + databaseLevelCollations + ))!; + this._provisioningScriptApiResponse = response; + + this._provisioningScriptResult = { + result: this._provisioningScriptApiResponse, + }; + + return this._provisioningScriptResult; + + } catch (error) { + logError(TelemetryViews.ProvisioningScriptWizard, 'GenerateProvisioningScriptFailed', error); + this._provisioningScriptResult = { + result: { + provisioningScriptFilePath: '', + }, + error: error, + }; + } + return this._provisioningScriptResult; + } + public async startPerfDataCollection( dataFolder: string, perfQueryIntervalInSec: number, @@ -1371,6 +1421,11 @@ export interface SkuRecommendation { recommendationError?: Error; } +export interface ProvisioningScript { + result: contracts.ProvisioningScriptResult; + error?: Error; +} + export interface OperationResult { success: boolean; result: T; diff --git a/extensions/sql-migration/src/service/contracts.ts b/extensions/sql-migration/src/service/contracts.ts index a25931c75ebd..58c59d808ff4 100644 --- a/extensions/sql-migration/src/service/contracts.ts +++ b/extensions/sql-migration/src/service/contracts.ts @@ -180,6 +180,7 @@ export interface SkuRecommendationResultItem { ranking: number; positiveJustifications: string[]; negativeJustifications: string[]; + recommendationReasonings: any[]; } export interface SqlInstanceRequirements { @@ -392,6 +393,21 @@ export namespace GetSqlMigrationSkuRecommendationsRequest { export const type = new RequestType('migration/getskurecommendations'); } +export interface DatabaseCollationMapping { + databaseName: string; + databaseCollation: string; +} + +export interface SqlMigrationGenerateProvisioningScriptParams { + skuRecommendations: SkuRecommendationResultItem[]; + serverLevelCollation: string; + databaseLevelCollations: DatabaseCollationMapping[]; +} + +export namespace SqlMigrationGenerateProvisioningScriptRequest { + export const type = new RequestType('migration/generateprovisioningscript'); +} + export interface SqlMigrationStartPerfDataCollectionParams { connectionString: string, dataFolder: string, @@ -400,6 +416,10 @@ export interface SqlMigrationStartPerfDataCollectionParams { numberOfIterations: number } +export interface ProvisioningScriptResult { + provisioningScriptFilePath: string; +} + export interface StartPerfDataCollectionResult { dateTimeStarted: Date; } @@ -484,6 +504,7 @@ export interface ISqlMigrationService { providerId: string; getAssessments(ownerUri: string, databases: string[], xEventsFilesFolderPath: string): Thenable; getSkuRecommendations(dataFolder: string, perfQueryIntervalInSec: number, targetPlatforms: string[], targetSqlInstance: string, targetPercentile: number, scalingFactor: number, startTime: string, endTime: string, includePreviewSkus: boolean, databaseAllowList: string[]): Promise; + generateProvisioningScript(skuRecommendations: SkuRecommendationResultItem[], serverLevelCollation: string, databaseLevelCollations: DatabaseCollationMapping[]): Promise; startPerfDataCollection(ownerUri: string, dataFolder: string, perfQueryIntervalInSec: number, staticQueryIntervalInSec: number, numberOfIterations: number): Promise; stopPerfDataCollection(): Promise; refreshPerfDataCollection(lastRefreshedTime: Date): Promise; diff --git a/extensions/sql-migration/src/service/features.ts b/extensions/sql-migration/src/service/features.ts index ca18937d3965..b6855c7ca35f 100644 --- a/extensions/sql-migration/src/service/features.ts +++ b/extensions/sql-migration/src/service/features.ts @@ -117,6 +117,26 @@ export class SqlMigrationService extends MigrationExtensionService implements co return undefined; } + async generateProvisioningScript( + skuRecommendations: contracts.SkuRecommendationResultItem[], + serverLevelCollation: string, + databaseLevelCollations: contracts.DatabaseCollationMapping[]): Promise { + let params: contracts.SqlMigrationGenerateProvisioningScriptParams = { + skuRecommendations, + serverLevelCollation, + databaseLevelCollations + }; + + try { + return this._client.sendRequest(contracts.SqlMigrationGenerateProvisioningScriptRequest.type, params); + } + catch (e) { + this._client.logFailedRequest(contracts.SqlMigrationGenerateProvisioningScriptRequest.type, e); + } + + return undefined; + } + async startPerfDataCollection( connectionString: string, dataFolder: string, diff --git a/extensions/sql-migration/src/telemetry.ts b/extensions/sql-migration/src/telemetry.ts index 7b8e60e686e9..f0801a02654b 100644 --- a/extensions/sql-migration/src/telemetry.ts +++ b/extensions/sql-migration/src/telemetry.ts @@ -35,6 +35,7 @@ export enum TelemetryViews { MigrationLocalStorage = 'MigrationLocalStorage', SkuRecommendationWizard = 'SkuRecommendationWizard', DataCollectionWizard = 'GetAzureRecommendationDialog', + ProvisioningScriptWizard = 'GenerateArmTemplateDialog', SelectMigrationServiceDialog = 'SelectMigrationServiceDialog', Utils = 'Utils', LoginMigrationWizardController = 'LoginMigrationWizardController', diff --git a/extensions/sql-migration/src/wizard/skuRecommendationPage.ts b/extensions/sql-migration/src/wizard/skuRecommendationPage.ts index f3416eef39c8..c7a50de7ead5 100644 --- a/extensions/sql-migration/src/wizard/skuRecommendationPage.ts +++ b/extensions/sql-migration/src/wizard/skuRecommendationPage.ts @@ -18,6 +18,7 @@ import { IconPath, IconPathHelper } from '../constants/iconPathHelper'; import { WIZARD_INPUT_COMPONENT_WIDTH } from './wizardController'; import * as styles from '../constants/styles'; import { SkuEditParametersDialog } from '../dialog/skuRecommendationResults/skuEditParametersDialog'; +import { GenerateProvisioningScriptDialog } from '../dialog/skuRecommendationResults/generateProvisioningScriptDialog'; import { logError, TelemetryViews } from '../telemetry'; import { TdeConfigurationDialog } from '../dialog/tdeConfiguration/tdeConfigurationDialog'; import { TdeMigrationModel } from '../models/tdeModels'; @@ -250,7 +251,7 @@ export class SKURecommendationPage extends MigrationWizardPage { iconHeight: '35px', iconWidth: '35px', cardWidth: '250px', - cardHeight: '340px', + cardHeight: '380px', iconPosition: 'left', ariaLabel: constants.SKU_RECOMMENDATION_CHOOSE_A_TARGET, CSSStyles: { @@ -324,6 +325,15 @@ export class SKURecommendationPage extends MigrationWizardPage { 'text-decoration': 'none', } }, + { + // 8 - CardDescriptionIndex.VIEW_TEMPLATE + textValue: '', + linkDisplayValue: '', + linkStyles: { + ...styles.BODY_CSS, + 'text-decoration': 'none', + } + }, ] }); @@ -332,9 +342,17 @@ export class SKURecommendationPage extends MigrationWizardPage { if (this.hasRecommendations()) { if (e.cardId === product.type) { const skuRecommendationResultsDialog = new SkuRecommendationResultsDialog(this.migrationStateModel, product.type); - await skuRecommendationResultsDialog.openDialog( - e.cardId, - this.migrationStateModel._skuRecommendationResults.recommendations); + const generateArmTemplateDialog = new GenerateProvisioningScriptDialog(this.migrationStateModel, product.type); + if (e.description.linkDisplayValue === e.card.descriptions[CardDescriptionIndex.VIEW_SKU_DETAILS].linkDisplayValue) { + if (e.cardId === skuRecommendationResultsDialog._targetType) { + await skuRecommendationResultsDialog.openDialog(e.cardId, this.migrationStateModel._skuRecommendationResults.recommendations); + } + } + else if (e.description.linkDisplayValue === e.card.descriptions[CardDescriptionIndex.VIEW_TEMPLATE].linkDisplayValue) { + if (e.cardId === skuRecommendationResultsDialog._targetType) { + await generateArmTemplateDialog.openDialog(e.cardId, this.migrationStateModel._skuRecommendationResults.recommendations); + } + } } } })); @@ -668,14 +686,9 @@ export class SKURecommendationPage extends MigrationWizardPage { if (!this.migrationStateModel._assessmentResults) { this._rbg.cards[index].descriptions[CardDescriptionIndex.ASSESSMENT_STATUS].textValue = ''; } else { - if (this.hasRecommendations()) { - this._rbg.cards[index].descriptions[CardDescriptionIndex.VIEW_SKU_DETAILS].linkDisplayValue = constants.VIEW_DETAILS; - this._rbg.cards[index].descriptions[CardDescriptionIndex.SKU_RECOMMENDATION].textStyles = { - ...styles.BODY_CSS, - 'font-weight': '500', - }; - } else { + if (!this.hasRecommendations()) { this._rbg.cards[index].descriptions[CardDescriptionIndex.VIEW_SKU_DETAILS].linkDisplayValue = ''; + this._rbg.cards[index].descriptions[CardDescriptionIndex.VIEW_TEMPLATE].linkDisplayValue = ''; this._rbg.cards[index].descriptions[CardDescriptionIndex.SKU_RECOMMENDATION].textStyles = { ...styles.BODY_CSS, }; @@ -695,7 +708,7 @@ export class SKURecommendationPage extends MigrationWizardPage { this._rbg.cards[index].descriptions[CardDescriptionIndex.ASSESSMENT_STATUS].textValue = constants.CAN_BE_MIGRATED(dbWithoutIssuesForMiCount, dbCount); - if (this.hasRecommendations()) { + if (this.hasRecommendations()) { ////// if (this.migrationStateModel._skuEnableElastic) { recommendation = this.migrationStateModel._skuRecommendationResults.recommendations?.elasticSqlMiRecommendationResults[0]; } else { @@ -706,8 +719,20 @@ export class SKURecommendationPage extends MigrationWizardPage { if (!recommendation?.targetSku) { this._rbg.cards[index].descriptions[CardDescriptionIndex.SKU_RECOMMENDATION].textValue = constants.SKU_RECOMMENDATION_NO_RECOMMENDATION; + this._rbg.cards[index].descriptions[CardDescriptionIndex.VIEW_SKU_DETAILS].linkDisplayValue = ''; + this._rbg.cards[index].descriptions[CardDescriptionIndex.VIEW_TEMPLATE].linkDisplayValue = ''; + this._rbg.cards[index].descriptions[CardDescriptionIndex.SKU_RECOMMENDATION].textStyles = { + ...styles.BODY_CSS, + }; } else { + this._rbg.cards[index].descriptions[CardDescriptionIndex.VIEW_SKU_DETAILS].linkDisplayValue = constants.VIEW_DETAILS; + this._rbg.cards[index].descriptions[CardDescriptionIndex.VIEW_TEMPLATE].linkDisplayValue = constants.TARGET_PROVISIONING_LINK; + this._rbg.cards[index].descriptions[CardDescriptionIndex.SKU_RECOMMENDATION].textStyles = { + ...styles.BODY_CSS, + 'font-weight': '500', + }; + const serviceTier = recommendation.targetSku.category?.sqlServiceTier === contracts.AzureSqlPaaSServiceTier.GeneralPurpose ? constants.GENERAL_PURPOSE : constants.BUSINESS_CRITICAL; @@ -739,8 +764,20 @@ export class SKURecommendationPage extends MigrationWizardPage { this._rbg.cards[index].descriptions[CardDescriptionIndex.SKU_RECOMMENDATION].textValue = constants.SKU_RECOMMENDATION_NO_RECOMMENDATION; this._rbg.cards[index].descriptions[CardDescriptionIndex.VM_CONFIGURATIONS].textValue = ''; + this._rbg.cards[index].descriptions[CardDescriptionIndex.VIEW_SKU_DETAILS].linkDisplayValue = ''; + this._rbg.cards[index].descriptions[CardDescriptionIndex.VIEW_TEMPLATE].linkDisplayValue = ''; + this._rbg.cards[index].descriptions[CardDescriptionIndex.SKU_RECOMMENDATION].textStyles = { + ...styles.BODY_CSS, + }; } else { + this._rbg.cards[index].descriptions[CardDescriptionIndex.VIEW_SKU_DETAILS].linkDisplayValue = constants.VIEW_DETAILS; + this._rbg.cards[index].descriptions[CardDescriptionIndex.VIEW_TEMPLATE].linkDisplayValue = constants.TARGET_PROVISIONING_LINK; + this._rbg.cards[index].descriptions[CardDescriptionIndex.SKU_RECOMMENDATION].textStyles = { + ...styles.BODY_CSS, + 'font-weight': '500', + }; + this._rbg.cards[index].descriptions[CardDescriptionIndex.SKU_RECOMMENDATION].textValue = constants.VM_CONFIGURATION( recommendation.targetSku.virtualMachineSize!.sizeName, @@ -774,6 +811,21 @@ export class SKURecommendationPage extends MigrationWizardPage { const successfulRecommendationsCount = recommendations.filter(r => r.targetSku !== null).length; this._rbg.cards[index].descriptions[CardDescriptionIndex.SKU_RECOMMENDATION].textValue = constants.RECOMMENDATIONS_AVAILABLE(successfulRecommendationsCount); + + if (successfulRecommendationsCount < 1) { + this._rbg.cards[index].descriptions[CardDescriptionIndex.VIEW_SKU_DETAILS].linkDisplayValue = ''; + this._rbg.cards[index].descriptions[CardDescriptionIndex.VIEW_TEMPLATE].linkDisplayValue = ''; + this._rbg.cards[index].descriptions[CardDescriptionIndex.SKU_RECOMMENDATION].textStyles = { + ...styles.BODY_CSS, + }; + } else { + this._rbg.cards[index].descriptions[CardDescriptionIndex.VIEW_SKU_DETAILS].linkDisplayValue = constants.VIEW_DETAILS; + this._rbg.cards[index].descriptions[CardDescriptionIndex.VIEW_TEMPLATE].linkDisplayValue = constants.TARGET_PROVISIONING_LINK; + this._rbg.cards[index].descriptions[CardDescriptionIndex.SKU_RECOMMENDATION].textStyles = { + ...styles.BODY_CSS, + 'font-weight': '500', + }; + } } break; } @@ -1348,4 +1400,5 @@ export enum CardDescriptionIndex { SKU_RECOMMENDATION = 5, VM_CONFIGURATIONS = 6, VIEW_SKU_DETAILS = 7, + VIEW_TEMPLATE = 8, } diff --git a/extensions/sql-migration/src/wizard/targetSelectionPage.ts b/extensions/sql-migration/src/wizard/targetSelectionPage.ts index 5e9e080f4853..30ed247bf013 100644 --- a/extensions/sql-migration/src/wizard/targetSelectionPage.ts +++ b/extensions/sql-migration/src/wizard/targetSelectionPage.ts @@ -18,6 +18,7 @@ import { collectTargetDatabaseInfo, TargetDatabaseInfo } from '../api/sqlUtils'; import { MigrationLocalStorage, MigrationServiceContext } from '../models/migrationLocalStorage'; import { TdeMigrationDialog } from '../dialog/tdeConfiguration/tdeMigrationDialog'; import { ValidationErrorCodes } from '../constants/helper'; +import { IconPathHelper } from '../constants/iconPathHelper'; const TDE_MIGRATION_BUTTON_INDEX = 1; @@ -786,12 +787,41 @@ export class TargetSelectionPage extends MigrationWizardPage { } })); + const azureResourceRefreshButton = this._view.modelBuilder.button().withProps({ + iconPath: IconPathHelper.refresh, + iconHeight: 18, + iconWidth: 18, + height: 25, + ariaLabel: constants.REFRESH, + }).component(); + this._disposables.push(azureResourceRefreshButton.onDidClick(async () => { + await this.populateSubscriptionDropdown(); + await this.populateLocationDropdown(); + await this.populateResourceGroupDropdown(); + await this.populateResourceInstanceDropdown(); + })); + + const azureResourceContainer = this._view.modelBuilder.flexContainer().component(); + + azureResourceContainer.addItem(this._azureResourceDropdown, { + flex: '0 0 auto' + }); + + azureResourceContainer.addItem(azureResourceRefreshButton, { + flex: '0 0 auto', + CSSStyles: { + 'margin-left': '5px', + 'margin-top': '-1em', + } + }); + return this._view.modelBuilder.flexContainer() .withItems([ this._azureResourceGroupLabel, this._azureResourceGroupDropdown, this._azureResourceDropdownLabel, - this._azureResourceDropdown]) + // this._azureResourceDropdown, + azureResourceContainer]) .withLayout({ flexFlow: 'column' }) .component(); }