diff --git a/extensions/sql-migration/src/api/azure.ts b/extensions/sql-migration/src/api/azure.ts index 12cb7516e7..34a5976ab4 100644 --- a/extensions/sql-migration/src/api/azure.ts +++ b/extensions/sql-migration/src/api/azure.ts @@ -213,6 +213,27 @@ export type SqlVMServer = { subscriptionId: string }; +export type VirtualMachineInstanceView = { + computerName: string, + osName: string, + osVersion: string, + vmAgent: { [propertyName: string]: string; }, + disks: { [propertyName: string]: string; }[], + bootDiagnostics: { [propertyName: string]: string; }, + extensions: { [propertyName: string]: string; }[], + hyperVGeneration: string, + patchStatus: { [propertyName: string]: string; }, + statuses: InstanceViewStatus[], +} + +export type InstanceViewStatus = { + code: string, + displayStatus: string, + level: string, + message: string, + time: string, +} + export async function getAvailableSqlDatabaseServers(account: azdata.Account, subscription: Subscription): Promise { const api = await getAzureCoreAPI(); const path = encodeURI(`/subscriptions/${subscription.id}/providers/Microsoft.Sql/servers?api-version=${SQL_SQLDB_API_VERSION}`); @@ -259,6 +280,19 @@ export async function getAvailableSqlVMs(account: azdata.Account, subscription: return response.response.data.value; } +export async function getVMInstanceView(sqlVm: SqlVMServer, account: azdata.Account, subscription: Subscription): Promise { + const api = await getAzureCoreAPI(); + const path = encodeURI(`/subscriptions/${subscription.id}/resourceGroups/${getResourceGroupFromId(sqlVm.id)}/providers/Microsoft.Compute/virtualMachines/${sqlVm.name}/instanceView?api-version=2022-08-01`); + const host = api.getProviderMetadataForAccount(account).settings.armResource?.endpoint; + const response = await api.makeAzureRestRequest(account, subscription, path, azurecore.HttpRequestMethod.GET, undefined, true, host); + + if (response.errors.length > 0) { + throw new Error(response.errors.toString()); + } + + return response.response.data; +} + export type StorageAccount = AzureProduct; export async function getAvailableStorageAccounts(account: azdata.Account, subscription: Subscription): Promise { const api = await getAzureCoreAPI(); diff --git a/extensions/sql-migration/src/api/utils.ts b/extensions/sql-migration/src/api/utils.ts index 2dbcd44c25..d7cdddfd89 100644 --- a/extensions/sql-migration/src/api/utils.ts +++ b/extensions/sql-migration/src/api/utils.ts @@ -548,7 +548,7 @@ export async function getManagedInstancesDropdownValues(managedInstances: azureR managedInstances.forEach((managedInstance) => { if (managedInstance.location.toLowerCase() === location.name.toLowerCase() && managedInstance.resourceGroup?.toLowerCase() === resourceGroup.name.toLowerCase()) { let managedInstanceValue: CategoryValue; - if (managedInstance.properties.state === 'Ready') { + if (managedInstance.properties.state.toLowerCase() === 'Ready'.toLowerCase()) { managedInstanceValue = { name: managedInstance.id, displayName: managedInstance.name @@ -618,6 +618,53 @@ export async function getVirtualMachines(account?: Account, subscription?: azure return virtualMachines; } +export async function getVirtualMachinesDropdownValues(virtualMachines: azure.SqlVMServer[], location: azureResource.AzureLocation, resourceGroup: azureResource.AzureResourceResourceGroup, account: Account, subscription: azureResource.AzureResourceSubscription): Promise { + let virtualMachinesValues: CategoryValue[] = []; + if (location && resourceGroup) { + for (const virtualMachine of virtualMachines) { + if (virtualMachine.location.toLowerCase() === location.name.toLowerCase() && azure.getResourceGroupFromId(virtualMachine.id).toLowerCase() === resourceGroup.name.toLowerCase()) { + let virtualMachineValue: CategoryValue; + + // 1) check if VM is on by querying underlying compute resource's instance view + let vmInstanceView = await azure.getVMInstanceView(virtualMachine, account, subscription); + if (!vmInstanceView.statuses.some(status => status.code.toLowerCase() === 'PowerState/running'.toLowerCase())) { + virtualMachineValue = { + name: virtualMachine.id, + displayName: constants.UNAVAILABLE_TARGET_PREFIX(virtualMachine.name) + } + } + + // 2) check for IaaS extension in Full mode + else if (virtualMachine.properties.sqlManagement.toLowerCase() !== 'Full'.toLowerCase()) { + virtualMachineValue = { + name: virtualMachine.id, + displayName: constants.UNAVAILABLE_TARGET_PREFIX(virtualMachine.name) + } + } + + else { + virtualMachineValue = { + name: virtualMachine.id, + displayName: virtualMachine.name + }; + } + + virtualMachinesValues.push(virtualMachineValue); + } + } + } + + if (virtualMachinesValues.length === 0) { + virtualMachinesValues = [ + { + displayName: constants.NO_VIRTUAL_MACHINE_FOUND, + name: '' + } + ]; + } + return virtualMachinesValues; +} + export async function getStorageAccounts(account?: Account, subscription?: azureResource.AzureResourceSubscription): Promise { let storageAccounts: azure.StorageAccount[] = []; try { diff --git a/extensions/sql-migration/src/constants/strings.ts b/extensions/sql-migration/src/constants/strings.ts index 17653dcd58..9eec709d04 100644 --- a/extensions/sql-migration/src/constants/strings.ts +++ b/extensions/sql-migration/src/constants/strings.ts @@ -488,7 +488,12 @@ export function ACCOUNT_ACCESS_ERROR(account: AzureAccount, error: Error) { export function MI_NOT_READY_ERROR(miName: string, state: string): string { return localize('sql.migration.mi.not.ready', "The managed instance '{0}' is unavailable for migration because it is currently in the '{1}' state. To continue, select an available managed instance.", miName, state); } - +export function VM_NOT_READY_IAAS_EXTENSION_ERROR(vmName: string, extensionState: string): string { + return localize('sql.migration.vm.not.ready.iaas.extension', "The virtual machine '{0}' is unavailable for migration because the SQL Server IaaS Agent extension is currently in '{1}' mode instead of Full mode. Learn more: https://aka.ms/sql-iaas-extension", vmName, extensionState); +} +export function VM_NOT_READY_POWER_STATE_ERROR(vmName: string): string { + return localize('sql.migration.vm.not.ready.power.state', "The virtual machine '{0}' is unavailable for migration because the underlying virtual machine is not running. Please make sure it is powered on before retrying.", vmName); +} export function SQLDB_NOT_READY_ERROR(sqldbName: string, state: string): string { return localize('sql.migration.sqldb.not.ready', "The SQL database server '{0}' is unavailable for migration because it is currently in the '{1}' state. To continue, select an available SQL database server.", sqldbName, state); } diff --git a/extensions/sql-migration/src/models/stateMachine.ts b/extensions/sql-migration/src/models/stateMachine.ts index 44db4f25f4..9447ecb418 100644 --- a/extensions/sql-migration/src/models/stateMachine.ts +++ b/extensions/sql-migration/src/models/stateMachine.ts @@ -7,7 +7,7 @@ import * as azdata from 'azdata'; import * as azurecore from 'azurecore'; import * as vscode from 'vscode'; import * as mssql from 'mssql'; -import { SqlMigrationService, SqlManagedInstance, startDatabaseMigration, StartDatabaseMigrationRequest, StorageAccount, SqlVMServer, getLocationDisplayName, getSqlManagedInstanceDatabases, AzureSqlDatabaseServer, isSqlManagedInstance, isAzureSqlDatabaseServer } from '../api/azure'; +import { SqlMigrationService, SqlManagedInstance, startDatabaseMigration, StartDatabaseMigrationRequest, StorageAccount, SqlVMServer, getLocationDisplayName, getSqlManagedInstanceDatabases, AzureSqlDatabaseServer, isSqlManagedInstance, isAzureSqlDatabaseServer, VirtualMachineInstanceView } from '../api/azure'; import * as constants from '../constants/strings'; import * as nls from 'vscode-nls'; import { v4 as uuidv4 } from 'uuid'; @@ -192,6 +192,7 @@ export class MigrationStateModel implements Model, vscode.Disposable { public _targetSqlVirtualMachines!: SqlVMServer[]; public _targetSqlDatabaseServers!: AzureSqlDatabaseServer[]; public _targetServerInstance!: SqlManagedInstance | SqlVMServer | AzureSqlDatabaseServer; + public _vmInstanceView!: VirtualMachineInstanceView; public _databaseBackup!: DatabaseBackupModel; public _storageAccounts!: StorageAccount[]; public _fileShares!: azurecore.azureResource.FileShare[]; diff --git a/extensions/sql-migration/src/wizard/targetSelectionPage.ts b/extensions/sql-migration/src/wizard/targetSelectionPage.ts index b66b69c347..3632b80adc 100644 --- a/extensions/sql-migration/src/wizard/targetSelectionPage.ts +++ b/extensions/sql-migration/src/wizard/targetSelectionPage.ts @@ -13,7 +13,7 @@ import * as styles from '../constants/styles'; import { WIZARD_INPUT_COMPONENT_WIDTH } from './wizardController'; import * as utils from '../api/utils'; import { azureResource } from 'azurecore'; -import { AzureSqlDatabaseServer, SqlVMServer } from '../api/azure'; +import { AzureSqlDatabaseServer, getVMInstanceView, SqlVMServer } from '../api/azure'; import { collectTargetDatabaseInfo, TargetDatabaseInfo } from '../api/sqlUtils'; import { MigrationLocalStorage, MigrationServiceContext } from '../models/migrationLocalStorage'; @@ -111,7 +111,7 @@ export class TargetSelectionPage extends MigrationWizardPage { if (!targetMi || resourceDropdownValue === constants.NO_MANAGED_INSTANCE_FOUND) { errors.push(constants.INVALID_MANAGED_INSTANCE_ERROR); } - if (targetMi && targetMi.properties?.state !== 'Ready') { + if (targetMi && targetMi.properties?.state.toLowerCase() !== 'Ready'.toLowerCase()) { errors.push(constants.MI_NOT_READY_ERROR(targetMi.name, targetMi.properties?.state)); } break; @@ -120,6 +120,17 @@ export class TargetSelectionPage extends MigrationWizardPage { if (!targetVm || resourceDropdownValue === constants.NO_VIRTUAL_MACHINE_FOUND) { errors.push(constants.INVALID_VIRTUAL_MACHINE_ERROR); } + + // validate power state from VM instance view + const vmInstanceView = this.migrationStateModel._vmInstanceView; + if (!vmInstanceView.statuses.some(status => status.code.toLowerCase() === 'PowerState/running'.toLowerCase())) { + errors.push(constants.VM_NOT_READY_POWER_STATE_ERROR(targetVm.name)); + } + + // validate IaaS extension mode + if (targetVm.properties.sqlManagement.toLowerCase() !== 'Full'.toLowerCase()) { + errors.push(constants.VM_NOT_READY_IAAS_EXTENSION_ERROR(targetVm.name, targetVm.properties.sqlManagement)); + } break; case MigrationTargetType.SQLDB: const targetSqlDB = this.migrationStateModel._targetServerInstance as AzureSqlDatabaseServer; @@ -127,7 +138,7 @@ export class TargetSelectionPage extends MigrationWizardPage { errors.push(constants.INVALID_SQL_DATABASE_ERROR); } // TODO: verify what state check is needed/possible? - if (targetSqlDB && targetSqlDB.properties?.state !== 'Ready') { + if (targetSqlDB && targetSqlDB.properties?.state.toLowerCase() !== 'Ready'.toLowerCase()) { errors.push(constants.SQLDB_NOT_READY_ERROR(targetSqlDB.name, targetSqlDB.properties.state)); } @@ -642,9 +653,29 @@ export class TargetSelectionPage extends MigrationWizardPage { switch (this.migrationStateModel._targetType) { case MigrationTargetType.SQLVM: - const selectedVm = this.migrationStateModel._targetSqlVirtualMachines?.find(vm => vm.name === value); + const selectedVm = this.migrationStateModel._targetSqlVirtualMachines?.find(vm => vm.name === value + || constants.UNAVAILABLE_TARGET_PREFIX(vm.name) === value); + if (selectedVm) { this.migrationStateModel._targetServerInstance = utils.deepClone(selectedVm)! as SqlVMServer; + this.migrationStateModel._vmInstanceView = await getVMInstanceView(this.migrationStateModel._targetServerInstance, this.migrationStateModel._azureAccount, this.migrationStateModel._targetSubscription); + this.wizard.message = { text: '' }; + + // validate power state from VM instance view + if (!this.migrationStateModel._vmInstanceView.statuses.some(status => status.code.toLowerCase() === 'PowerState/running'.toLowerCase())) { + this.wizard.message = { + text: constants.VM_NOT_READY_POWER_STATE_ERROR(this.migrationStateModel._targetServerInstance.name), + level: azdata.window.MessageLevel.Error + }; + } + + // validate IaaS extension mode + if (this.migrationStateModel._targetServerInstance.properties.sqlManagement.toLowerCase() !== 'Full'.toLowerCase()) { + this.wizard.message = { + text: constants.VM_NOT_READY_IAAS_EXTENSION_ERROR(this.migrationStateModel._targetServerInstance.name, this.migrationStateModel._targetServerInstance.properties.sqlManagement), + level: azdata.window.MessageLevel.Error + }; + } } break; case MigrationTargetType.SQLMI: @@ -654,9 +685,9 @@ export class TargetSelectionPage extends MigrationWizardPage { if (selectedMi) { this.migrationStateModel._targetServerInstance = utils.deepClone(selectedMi)! as azureResource.AzureSqlManagedInstance; - this.wizard.message = { text: '' }; - if (this.migrationStateModel._targetServerInstance.properties.state !== 'Ready') { + + if (this.migrationStateModel._targetServerInstance.properties.state.toLowerCase() !== 'Ready'.toLowerCase()) { this.wizard.message = { text: constants.MI_NOT_READY_ERROR( this.migrationStateModel._targetServerInstance.name, @@ -673,7 +704,7 @@ export class TargetSelectionPage extends MigrationWizardPage { if (sqlDatabaseServer) { this.migrationStateModel._targetServerInstance = utils.deepClone(sqlDatabaseServer)! as AzureSqlDatabaseServer; this.wizard.message = { text: '' }; - if (this.migrationStateModel._targetServerInstance.properties.state === 'Ready') { + if (this.migrationStateModel._targetServerInstance.properties.state.toLowerCase() === 'Ready'.toLowerCase()) { this._targetUserNameInputBox.value = this.migrationStateModel._targetServerInstance.properties.administratorLogin; } else { this.wizard.message = { @@ -941,11 +972,12 @@ export class TargetSelectionPage extends MigrationWizardPage { this.migrationStateModel._resourceGroup); break; case MigrationTargetType.SQLVM: - this._azureResourceDropdown.values = utils.getAzureResourceDropdownValues( + this._azureResourceDropdown.values = await utils.getVirtualMachinesDropdownValues( this.migrationStateModel._targetSqlVirtualMachines, this.migrationStateModel._location, - this.migrationStateModel._resourceGroup?.name, - constants.NO_VIRTUAL_MACHINE_FOUND); + this.migrationStateModel._resourceGroup, + this.migrationStateModel._azureAccount, + this.migrationStateModel._targetSubscription); break; case MigrationTargetType.SQLDB: this._azureResourceDropdown.values = utils.getAzureResourceDropdownValues(