diff --git a/extensions/sql-migration/src/api/utils.ts b/extensions/sql-migration/src/api/utils.ts index d7cdddfd89..32b23d0595 100644 --- a/extensions/sql-migration/src/api/utils.ts +++ b/extensions/sql-migration/src/api/utils.ts @@ -73,6 +73,19 @@ export function getSqlServerName(majorVersion: number): string | undefined { } } +export function isTargetSqlVm2014OrBelow(sqlVm: azure.SqlVMServer): boolean { + // e.g. SQL2008-WS2012, SQL2008R2-WS2019, SQL2012-WS2016, SQL2014-WS2012R2, SQL2016-WS2019, SQL2017-WS2019, SQL2019-WS2022 + const sqlImageOffer = sqlVm.properties.sqlImageOffer; + + // parse image offer and extract SQL version (assuming it is a valid image offer) + if (sqlImageOffer && sqlImageOffer.toUpperCase().startsWith('SQL')) { + const version = parseInt(sqlImageOffer.substring(3, 7)); + return version <= 2014; + } + + return false; +} + export interface IPackageInfo { name: string; version: string; diff --git a/extensions/sql-migration/src/constants/strings.ts b/extensions/sql-migration/src/constants/strings.ts index b62816c81a..32e75dd5a3 100644 --- a/extensions/sql-migration/src/constants/strings.ts +++ b/extensions/sql-migration/src/constants/strings.ts @@ -524,6 +524,8 @@ export const DATA_SOURCE_CONFIGURATION_PAGE_TITLE = localize('sql.migration.data export const DATABASE_BACKUP_PAGE_DESCRIPTION = localize('sql.migration.database.page.description', "Select the location of the database backups to use during migration."); export const DATABASE_BACKUP_NC_NETWORK_SHARE_RADIO_LABEL = localize('sql.migration.nc.network.share.radio.label', "My database backups are on a network share"); export const DATABASE_BACKUP_NC_BLOB_STORAGE_RADIO_LABEL = localize('sql.migration.nc.blob.storage.radio.label', "My database backups are in an Azure Storage Blob Container"); +export const DATABASE_BACKUP_SQL_VM_PAGE_BLOB_INFO = localize('sql.migration.sql.vm.page.blob.info', "For target servers running SQL Server 2014 or below, you must store your database backups in an Azure Storage Blob Container instead of uploading them using the network share option. Additionally, you must store the backup files as page blobs, as block blobs are supported only for targets running SQL Server 2016 or later. Learn more: {0}"); +export const DATABASE_BACKUP_SQL_VM_PAGE_BLOB_URL_LABEL = localize('sql.migration.sql.vm.page.blob.url.label', "Known issues, limitations, and troubleshooting"); export const DATABASE_BACKUP_NETWORK_SHARE_HEADER_TEXT = localize('sql.migration.network.share.header.text', "Network share details"); export const DATABASE_BACKUP_NETWORK_SHARE_LOCATION_INFO = localize('sql.migration.network.share.location.info', "Network share path for your database backups. The migration process will automatically retrieve valid backup files from this network share."); export const DATABASE_BACKUP_NETWORK_SHARE_WINDOWS_USER_INFO = localize('sql.migration.network.share.windows.user.info', "Windows user account with read access to the network share location."); @@ -604,6 +606,9 @@ export function INVALID_BLOB_CONTAINER_ERROR(sourceDb: string): string { export function INVALID_BLOB_LAST_BACKUP_FILE_ERROR(sourceDb: string): string { return localize('sql.migration.invalid.blob.lastBackupFile.error', "To continue, select a valid last backup file for source database '{0}'.", sourceDb); } +export function INVALID_NON_PAGE_BLOB_BACKUP_FILE_ERROR(sourceDb: string): string { + return localize('sql.migration.invalid.non.page.blob.backupFile.error', "To continue, select a blob container where all the backup files are page blobs for source database '{0}', as block blobs are supported only for targets running SQL Server 2016 or later. Learn more: https://aka.ms/dms-migrations-troubleshooting", sourceDb); +} export const INVALID_NETWORK_SHARE_LOCATION = localize('sql.migration.invalid.network.share.location', "Invalid network share location format. Example: {0}", NETWORK_SHARE_PATH_FORMAT); export const INVALID_USER_ACCOUNT = localize('sql.migration.invalid.user.account', "Invalid user account format. Example: {0}", WINDOWS_USER_ACCOUNT); export const INVALID_TARGET_NAME_ERROR = localize('sql.migration.invalid.target.name.error', "Enter a valid name for the target database."); diff --git a/extensions/sql-migration/src/wizard/databaseBackupPage.ts b/extensions/sql-migration/src/wizard/databaseBackupPage.ts index f36237cd59..ddefd409eb 100644 --- a/extensions/sql-migration/src/wizard/databaseBackupPage.ts +++ b/extensions/sql-migration/src/wizard/databaseBackupPage.ts @@ -6,7 +6,7 @@ import * as azdata from 'azdata'; import * as vscode from 'vscode'; import { EOL } from 'os'; -import { getStorageAccountAccessKeys } from '../api/azure'; +import { getStorageAccountAccessKeys, SqlVMServer } from '../api/azure'; import { MigrationWizardPage } from '../models/migrationWizardPage'; import { Blob, MigrationMode, MigrationSourceAuthenticationType, MigrationStateModel, MigrationTargetType, NetworkContainerType, NetworkShare, StateChangeEvent, ValidateIrState, ValidationResult } from '../models/stateMachine'; import * as constants from '../constants/strings'; @@ -66,6 +66,7 @@ export class DatabaseBackupPage extends MigrationWizardPage { private _networkDetailsContainer!: azdata.FlexContainer; private _existingDatabases: string[] = []; + private _nonPageBlobErrors: string[] = []; private _disposables: vscode.Disposable[] = []; // SQL DB table selection @@ -696,29 +697,33 @@ export class DatabaseBackupPage extends MigrationWizardPage { break; case NetworkContainerType.BLOB_CONTAINER: this._blobContainerResourceGroupDropdowns.forEach((v, index) => { - if (this.shouldDisplayBlobDropdownError(v, [constants.RESOURCE_GROUP_NOT_FOUND])) { + if (this.shouldDisplayBlobDropdownError(v, blobResourceGroupErrorStrings)) { errors.push(constants.INVALID_BLOB_RESOURCE_GROUP_ERROR(this.migrationStateModel._databasesForMigration[index])); } }); this._blobContainerStorageAccountDropdowns.forEach((v, index) => { - if (this.shouldDisplayBlobDropdownError(v, [constants.NO_STORAGE_ACCOUNT_FOUND, constants.SELECT_RESOURCE_GROUP_PROMPT])) { + if (this.shouldDisplayBlobDropdownError(v, blobStorageAccountErrorStrings)) { errors.push(constants.INVALID_BLOB_STORAGE_ACCOUNT_ERROR(this.migrationStateModel._databasesForMigration[index])); } }); this._blobContainerDropdowns.forEach((v, index) => { - if (this.shouldDisplayBlobDropdownError(v, [constants.NO_BLOBCONTAINERS_FOUND, constants.SELECT_STORAGE_ACCOUNT])) { + if (this.shouldDisplayBlobDropdownError(v, blobContainerErrorStrings)) { errors.push(constants.INVALID_BLOB_CONTAINER_ERROR(this.migrationStateModel._databasesForMigration[index])); } }); if (this.migrationStateModel._databaseBackup.migrationMode === MigrationMode.OFFLINE) { this._blobContainerLastBackupFileDropdowns.forEach((v, index) => { - if (this.shouldDisplayBlobDropdownError(v, [constants.NO_BLOBFILES_FOUND, constants.SELECT_BLOB_CONTAINER])) { + if (this.shouldDisplayBlobDropdownError(v, blobFileErrorStrings)) { errors.push(constants.INVALID_BLOB_LAST_BACKUP_FILE_ERROR(this.migrationStateModel._databasesForMigration[index])); } }); } + if (this.migrationStateModel.isSqlVmTarget && utils.isTargetSqlVm2014OrBelow(this.migrationStateModel._targetServerInstance as SqlVMServer)) { + errors.push(...this._nonPageBlobErrors); + } + if (errors.length > 0) { const duplicates: Map = new Map(); for (let i = 0; i < this.migrationStateModel._targetDatabaseNames.length; i++) { @@ -1047,6 +1052,23 @@ export class DatabaseBackupPage extends MigrationWizardPage { const selectedBlobContainer = this.migrationStateModel._blobContainers.find(blob => blob.name === value); if (selectedBlobContainer && !blobContainerErrorStrings.includes(value)) { this.migrationStateModel._databaseBackup.blobs[index].blobContainer = selectedBlobContainer; + + if (this.migrationStateModel.isSqlVmTarget && utils.isTargetSqlVm2014OrBelow(this.migrationStateModel._targetServerInstance as SqlVMServer)) { + const backups = await utils.getBlobLastBackupFileNames( + this.migrationStateModel._azureAccount, + this.migrationStateModel._databaseBackup.subscription, + this.migrationStateModel._databaseBackup.blobs[index]?.storageAccount, + this.migrationStateModel._databaseBackup.blobs[index]?.blobContainer); + + const errorMessage = constants.INVALID_NON_PAGE_BLOB_BACKUP_FILE_ERROR(this.migrationStateModel._databasesForMigration[index]); + this._nonPageBlobErrors = this._nonPageBlobErrors.filter(err => err !== errorMessage); + + const allBackupsPageBlob = backups.every(backup => backup.properties.blobType === 'PageBlob') + if (!allBackupsPageBlob) { + this._nonPageBlobErrors.push(errorMessage); + } + } + if (this.migrationStateModel._databaseBackup.migrationMode === MigrationMode.OFFLINE) { await this.loadBlobLastBackupFileDropdown(index); await blobContainerLastBackupFileDropdown.updateProperties({ enabled: true }); diff --git a/extensions/sql-migration/src/wizard/integrationRuntimePage.ts b/extensions/sql-migration/src/wizard/integrationRuntimePage.ts index f6d09f1cce..f9b68c51e8 100644 --- a/extensions/sql-migration/src/wizard/integrationRuntimePage.ts +++ b/extensions/sql-migration/src/wizard/integrationRuntimePage.ts @@ -10,7 +10,7 @@ import { MigrationMode, MigrationStateModel, NetworkContainerType, StateChangeEv import { CreateSqlMigrationServiceDialog } from '../dialog/createSqlMigrationService/createSqlMigrationServiceDialog'; import * as constants from '../constants/strings'; import { WIZARD_INPUT_COMPONENT_WIDTH } from './wizardController'; -import { getFullResourceGroupFromId, getLocationDisplayName, getSqlMigrationService, getSqlMigrationServiceAuthKeys, getSqlMigrationServiceMonitoringData } from '../api/azure'; +import { getFullResourceGroupFromId, getLocationDisplayName, getSqlMigrationService, getSqlMigrationServiceAuthKeys, getSqlMigrationServiceMonitoringData, SqlVMServer } from '../api/azure'; import { IconPathHelper } from '../constants/iconPathHelper'; import { logError, TelemetryViews } from '../telemtery'; import * as utils from '../api/utils'; @@ -38,6 +38,7 @@ export class IntergrationRuntimePage extends MigrationWizardPage { private _radioButtonContainer!: azdata.FlexContainer; private _networkShareButton!: azdata.RadioButtonComponent; private _blobContainerButton!: azdata.RadioButtonComponent; + private _sqlVmPageBlobInfoBox!: azdata.TextComponent; private _originalMigrationMode!: MigrationMode; private _disposables: vscode.Disposable[] = []; @@ -178,11 +179,26 @@ export class IntergrationRuntimePage extends MigrationWizardPage { } })); + this._sqlVmPageBlobInfoBox = this._view.modelBuilder.infoBox() + .withProps({ + text: constants.DATABASE_BACKUP_SQL_VM_PAGE_BLOB_INFO, + style: 'information', + width: WIZARD_INPUT_COMPONENT_WIDTH, + CSSStyles: { ...styles.BODY_CSS, 'display': 'none' }, + links: [ + { + text: constants.DATABASE_BACKUP_SQL_VM_PAGE_BLOB_URL_LABEL, + url: 'https://aka.ms/dms-migrations-troubleshooting' + } + ] + }).component(); + const flexContainer = this._view.modelBuilder.flexContainer() .withItems([ selectLocationText, this._blobContainerButton, this._networkShareButton, + this._sqlVmPageBlobInfoBox ]) .withLayout({ flexFlow: 'column' }) .component(); @@ -192,6 +208,7 @@ export class IntergrationRuntimePage extends MigrationWizardPage { public async onPageEnter(pageChangeInfo: azdata.window.WizardPageChangeInfo): Promise { const isSqlDbTarget = this.migrationStateModel.isSqlDbTarget; + const isSqlVmTarget = this.migrationStateModel.isSqlVmTarget; const isNetworkShare = this.migrationStateModel.isBackupContainerNetworkShare; this.wizard.registerNavigationValidator((pageChangeInfo) => { @@ -245,6 +262,15 @@ export class IntergrationRuntimePage extends MigrationWizardPage { this._radioButtonContainer, !isSqlDbTarget); + // if target SQL VM version is <= 2014, disable IR scenario and show info box + const shouldDisableIrScenario = isSqlVmTarget && utils.isTargetSqlVm2014OrBelow(this.migrationStateModel._targetServerInstance as SqlVMServer); + this._networkShareButton.enabled = !shouldDisableIrScenario; + await utils.updateControlDisplay(this._sqlVmPageBlobInfoBox, shouldDisableIrScenario, 'block'); + + // always pre-select blob scenario + this.migrationStateModel._databaseBackup.networkContainerType = NetworkContainerType.BLOB_CONTAINER; + this._blobContainerButton.checked = true; + this._subscription.value = this.migrationStateModel._targetSubscription.name; this._location.value = await getLocationDisplayName( this.migrationStateModel._targetServerInstance.location);