[SQL Migration] Add storage/MI connectivity validation (#22410)

* wip

* Add SQL VM POC

* Undo azurecore changes

* Add warning banner instead of blocking on next

* Fix warning banner behavior

* Add private endpoint support

* Fix navigation issue

* Add offline scenario

* Address PR comments

* Fix merge conflicts
This commit is contained in:
Raymond Truong
2023-03-29 12:48:22 -07:00
committed by GitHub
parent e70865ff20
commit 4867a3747c
4 changed files with 164 additions and 11 deletions

View File

@@ -32,7 +32,8 @@ export interface NetworkInterfaceIpConfiguration extends NetworkResource {
privateIPAddress: string,
privateIPAddressVersion: string,
provisioningState: string,
publicIPAddress: NetworkResource
publicIPAddress: NetworkResource,
subnet: { id: string }
}
}
@@ -42,6 +43,19 @@ export interface PublicIpAddress extends NetworkResource {
}
}
export interface PrivateEndpointConnection extends NetworkResource {
properties: {
privateEndpoint: { id: string },
privateLinkServiceConnectionState: { description: string, status: string }
}
}
export interface PrivateEndpoint extends NetworkResource {
properties: {
subnet: { id: string }
}
}
export class NetworkInterfaceModel {
public static IPv4VersionType = "IPv4".toLocaleLowerCase();
private static NETWORK_API_VERSION = '2022-09-01';
@@ -145,4 +159,13 @@ export class NetworkInterfaceModel {
return networkInterfaces;
}
public static async getPrivateEndpoint(account: azdata.Account, subscription: Subscription, privateEndpointId: string): Promise<PrivateEndpoint> {
return getAzureResourceGivenId(account, subscription, privateEndpointId, this.NETWORK_API_VERSION);
}
public static getVirtualNetworkFromSubnet(subnetId: string): string {
return subnetId.replace(RegExp('^(.*?)/virtualNetworks/'), '').replace(RegExp('/subnets/.*'), '').toLowerCase();
}
}

View File

@@ -5,11 +5,12 @@
import * as azdata from 'azdata';
import { azureResource } from 'azurecore';
import { AzureSqlDatabase, AzureSqlDatabaseServer } from './azure';
import { generateGuid } from './utils';
import { AzureSqlDatabase, AzureSqlDatabaseServer, SqlManagedInstance, SqlVMServer, StorageAccount, Subscription } from './azure';
import { generateGuid, MigrationTargetType } from './utils';
import * as utils from '../api/utils';
import { TelemetryAction, TelemetryViews, logError } from '../telemetry';
import * as constants from '../constants/strings';
import { NetworkInterfaceModel, PrivateEndpointConnection } from './dataModels/azure/networkInterfaceModel';
const query_database_tables_sql = `
SELECT
@@ -509,3 +510,72 @@ export async function isSourceConnectionSysAdmin(): Promise<boolean> {
return getSqlBoolean(results.rows[0][0]);
}
export async function canTargetConnectToStorageAccount(
targetType: MigrationTargetType,
targetServer: SqlManagedInstance | SqlVMServer | AzureSqlDatabaseServer,
storageAccount: StorageAccount,
account: azdata.Account,
subscription: Subscription): Promise<boolean> {
// additional ARM properties of storage accounts which aren't exposed in azurecore
interface StorageAccountAdditionalProperties {
publicNetworkAccess: string,
networkAcls: NetworkRuleSet,
privateEndpointConnections: PrivateEndpointConnection[]
}
interface NetworkRuleSet {
virtualNetworkRules: VirtualNetworkRule[],
defaultAction: string
}
interface VirtualNetworkRule {
id: string,
state: string,
action: string
}
const ENABLED = 'Enabled';
const ALLOW = 'Allow';
const storageAccountProperties: StorageAccountAdditionalProperties = (storageAccount as any)['properties'];
const storageAccountPublicAccessEnabled: boolean = storageAccountProperties.publicNetworkAccess ? storageAccountProperties.publicNetworkAccess.toLowerCase() === ENABLED.toLowerCase() : true;
const storageAccountDefaultIsAllow: boolean = storageAccountProperties.networkAcls ? storageAccountProperties.networkAcls.defaultAction.toLowerCase() === ALLOW.toLowerCase() : true;
const storageAccountWhitelistedVNets: string[] = storageAccountProperties.networkAcls ? storageAccountProperties.networkAcls.virtualNetworkRules.filter(rule => rule.action.toLowerCase() === ALLOW.toLowerCase()).map(rule => rule.id) : [];
var enabledFromAllNetworks: boolean = false;
var enabledFromWhitelistedVNet: boolean = false;
var enabledFromPrivateEndpoint: boolean = false;
// 1) check for access from all networks
enabledFromAllNetworks = storageAccountPublicAccessEnabled && storageAccountDefaultIsAllow;
switch (targetType) {
case MigrationTargetType.SQLMI:
const targetManagedInstanceVNet: string = (targetServer.properties as any)['subnetId'] ?? '';
const targetManagedInstancePrivateEndpointConnections: PrivateEndpointConnection[] = (targetServer.properties as any)['privateEndpointConnections'] ?? [];
const storageAccountPrivateEndpointConnections: PrivateEndpointConnection[] = storageAccountProperties.privateEndpointConnections ?? [];
// 2) check for access from whitelisted vnet
if (storageAccountWhitelistedVNets.length > 0) {
enabledFromWhitelistedVNet = storageAccountWhitelistedVNets.some(vnet => vnet.toLowerCase() === targetManagedInstanceVNet.toLowerCase());
}
// 3) check for access from private endpoint
if (targetManagedInstancePrivateEndpointConnections.length > 0) {
enabledFromPrivateEndpoint = storageAccountPrivateEndpointConnections.some(async privateEndpointConnection => {
const privateEndpoint = await NetworkInterfaceModel.getPrivateEndpoint(account, subscription, privateEndpointConnection.id);
const privateEndpointSubnet = privateEndpoint.properties.subnet ? privateEndpoint.properties.subnet.id : '';
return NetworkInterfaceModel.getVirtualNetworkFromSubnet(privateEndpointSubnet).toLowerCase() === NetworkInterfaceModel.getVirtualNetworkFromSubnet(targetManagedInstanceVNet).toLowerCase();
});
}
break;
case MigrationTargetType.SQLVM:
// to-do: VM scenario -- get subnet by first checking underlying compute VM, then its network interface
return true;
default:
return true;
}
return enabledFromAllNetworks || enabledFromWhitelistedVNet || enabledFromPrivateEndpoint;
}

View File

@@ -612,6 +612,11 @@ export const INVALID_RESOURCE_GROUP_ERROR = localize('sql.migration.invalid.reso
export const INVALID_STORAGE_ACCOUNT_ERROR = localize('sql.migration.invalid.storageAccount.error', "To continue, select a valid storage account.");
export const MISSING_TARGET_USERNAME_ERROR = localize('sql.migration.missing.targetUserName.error', "To continue, enter a valid target user name.");
export const MISSING_TARGET_PASSWORD_ERROR = localize('sql.migration.missing.targetPassword.error', "To continue, enter a valid target password.");
export function STORAGE_ACCOUNT_CONNECTIVITY_WARNING(targetServer: string, databases: string[]): string {
return databases.length === 1
? localize('sql.migration.storageAccount.warning.many', "Target instance '{0}' may not be able to access storage account '{1}'. Ensure that the subnet of the target instance is whitelisted on the storage account, and if applicable, that the private endpoint is in the same virtual network as the target server.", targetServer, databases[0])
: localize('sql.migration.storageAccount.warning.one', "Target instance '{0}' may not be able to access storage accounts '{1}'. Ensure that the subnet of the target instance is whitelisted on the storage accounts, and if applicable, that the private endpoints are on the same virtual network as the target server.", targetServer, databases.join("', '"));
}
export const TARGET_TABLE_NOT_EMPTY = localize('sql.migration.target.table.not.empty', "Target table is not empty.");
export const TARGET_TABLE_MISSING = localize('sql.migration.target.table.missing', "Target table does not exist");

View File

@@ -18,7 +18,7 @@ import { logError, TelemetryViews } from '../telemetry';
import * as styles from '../constants/styles';
import { TableMigrationSelectionDialog } from '../dialog/tableMigrationSelection/tableMigrationSelectionDialog';
import { ValidateIrDialog } from '../dialog/validationResults/validateIrDialog';
import { getSourceConnectionCredentials, getSourceConnectionProfile, getSourceConnectionQueryProvider, getSourceConnectionUri } from '../api/sqlUtils';
import { canTargetConnectToStorageAccount, getSourceConnectionCredentials, getSourceConnectionProfile, getSourceConnectionQueryProvider, getSourceConnectionUri } from '../api/sqlUtils';
const WIZARD_TABLE_COLUMN_WIDTH = '200px';
const WIZARD_TABLE_COLUMN_WIDTH_SMALL = '170px';
@@ -71,6 +71,7 @@ export class DatabaseBackupPage extends MigrationWizardPage {
private _existingDatabases: string[] = [];
private _nonPageBlobErrors: string[] = [];
private _inaccessibleStorageAccounts: string[] = [];
private _disposables: vscode.Disposable[] = [];
// SQL DB table selection
@@ -605,7 +606,7 @@ export class DatabaseBackupPage extends MigrationWizardPage {
fireOnTextChange: true,
}).component();
this._disposables.push(
this._networkShareContainerStorageAccountDropdown.onValueChanged((value) => {
this._networkShareContainerStorageAccountDropdown.onValueChanged(async (value) => {
if (value && value !== 'undefined') {
const selectedStorageAccount = this.migrationStateModel._storageAccounts.find(sa => sa.name === value);
if (selectedStorageAccount) {
@@ -613,6 +614,22 @@ export class DatabaseBackupPage extends MigrationWizardPage {
this.migrationStateModel._databaseBackup.networkShares[i].storageAccount = selectedStorageAccount;
}
this.migrationStateModel.resetIrValidationResults();
// check for storage account connectivity
if ((this.migrationStateModel.isSqlMiTarget || this.migrationStateModel.isSqlVmTarget)) {
if (!(await canTargetConnectToStorageAccount(this.migrationStateModel._targetType, this.migrationStateModel._targetServerInstance, selectedStorageAccount, this.migrationStateModel._azureAccount, this.migrationStateModel._targetSubscription))) {
this._inaccessibleStorageAccounts = [selectedStorageAccount.name];
} else {
this._inaccessibleStorageAccounts = [];
}
this.wizard.message = {
text: this._inaccessibleStorageAccounts.length > 0
? constants.STORAGE_ACCOUNT_CONNECTIVITY_WARNING(this.migrationStateModel._targetServerInstance.name, this._inaccessibleStorageAccounts)
: '',
level: azdata.window.MessageLevel.Warning
}
}
}
}
}));
@@ -628,7 +645,26 @@ export class DatabaseBackupPage extends MigrationWizardPage {
this._disposables.push(
this._networkShareContainerStorageAccountRefreshButton.onDidClick(
value => this.loadNetworkShareStorageDropdown()));
async () => {
this.loadNetworkShareStorageDropdown();
// check for storage account connectivity
const selectedStorageAccount = this.migrationStateModel._storageAccounts.find(sa => sa.name === (this._networkShareContainerStorageAccountDropdown.value as azdata.CategoryValue).displayName);
if ((this.migrationStateModel.isSqlMiTarget || this.migrationStateModel.isSqlVmTarget) && selectedStorageAccount) {
if (!(await canTargetConnectToStorageAccount(this.migrationStateModel._targetType, this.migrationStateModel._targetServerInstance, selectedStorageAccount, this.migrationStateModel._azureAccount, this.migrationStateModel._targetSubscription))) {
this._inaccessibleStorageAccounts = [selectedStorageAccount.name];
} else {
this._inaccessibleStorageAccounts = [];
}
this.wizard.message = {
text: this._inaccessibleStorageAccounts.length > 0
? constants.STORAGE_ACCOUNT_CONNECTIVITY_WARNING(this.migrationStateModel._targetServerInstance.name, this._inaccessibleStorageAccounts)
: '',
level: azdata.window.MessageLevel.Warning
}
}
}));
const storageAccountContainer = this._view.modelBuilder.flexContainer()
.withProps({ CSSStyles: { 'margin-top': '-1em' } })
@@ -825,10 +861,6 @@ export class DatabaseBackupPage extends MigrationWizardPage {
}
this._blobContainerTargetDatabaseNamesTable.columns[folderColumnIndex].hidden = folderColumnNewHidden;
const connectionProfile = await getSourceConnectionProfile();
const queryProvider = await getSourceConnectionQueryProvider();
let username = '';
@@ -871,6 +903,7 @@ export class DatabaseBackupPage extends MigrationWizardPage {
this._blobContainerDropdowns = [];
this._blobContainerLastBackupFileDropdowns = [];
this._blobContainerFolderDropdowns = [];
this._inaccessibleStorageAccounts = [];
if (this.migrationStateModel.isSqlMiTarget) {
this._existingDatabases = await this.migrationStateModel.getManagedDatabases();
@@ -1073,7 +1106,28 @@ export class DatabaseBackupPage extends MigrationWizardPage {
if (value && value !== 'undefined') {
const selectedStorageAccount = this.migrationStateModel._storageAccounts.find(sa => sa.name === value);
if (selectedStorageAccount && !blobStorageAccountErrorStrings.includes(value)) {
const oldSelectedStorageAccount = this.migrationStateModel._databaseBackup.blobs[index].storageAccount ? this.migrationStateModel._databaseBackup.blobs[index].storageAccount.name : '';
this.migrationStateModel._databaseBackup.blobs[index].storageAccount = selectedStorageAccount;
// check for storage account connectivity
if ((this.migrationStateModel.isSqlMiTarget || this.migrationStateModel.isSqlVmTarget)) {
if (this.migrationStateModel._databaseBackup.blobs.filter((e, i) => i !== index).every(blob => blob.storageAccount && blob.storageAccount.name.toLowerCase() !== oldSelectedStorageAccount.toLowerCase())) {
this._inaccessibleStorageAccounts = this._inaccessibleStorageAccounts.filter(storageAccountName => storageAccountName.toLowerCase() !== oldSelectedStorageAccount.toLowerCase());
}
if (!(await canTargetConnectToStorageAccount(this.migrationStateModel._targetType, this.migrationStateModel._targetServerInstance, selectedStorageAccount, this.migrationStateModel._azureAccount, this.migrationStateModel._targetSubscription))) {
this._inaccessibleStorageAccounts = this._inaccessibleStorageAccounts.filter(storageAccountName => storageAccountName.toLowerCase() !== selectedStorageAccount.name.toLowerCase());
this._inaccessibleStorageAccounts.push(selectedStorageAccount.name);
}
this.wizard.message = {
text: this._inaccessibleStorageAccounts.length > 0
? constants.STORAGE_ACCOUNT_CONNECTIVITY_WARNING(this.migrationStateModel._targetServerInstance.name, this._inaccessibleStorageAccounts)
: '',
level: azdata.window.MessageLevel.Warning
}
}
await this.loadBlobContainerDropdown(index);
await blobContainerDropdown.updateProperties({ enabled: true });
} else {
@@ -1090,6 +1144,7 @@ export class DatabaseBackupPage extends MigrationWizardPage {
if (selectedBlobContainer && !blobContainerErrorStrings.includes(value)) {
this.migrationStateModel._databaseBackup.blobs[index].blobContainer = selectedBlobContainer;
// check for block blobs for SQL VM <= 2014
if (this.migrationStateModel.isSqlVmTarget && utils.isTargetSqlVm2014OrBelow(this.migrationStateModel._targetServerInstance as SqlVMServer)) {
const backups = await utils.getBlobLastBackupFileNames(
this.migrationStateModel._azureAccount,
@@ -1137,7 +1192,7 @@ export class DatabaseBackupPage extends MigrationWizardPage {
this._disposables.push(
blobContainerFolderDropdown.onValueChanged(value => {
if (value && value !== 'undefined') {
if (this.migrationStateModel._blobContainerFolders.includes(value) && !blobFolderErrorStrings.includes(value)) {
if (this.migrationStateModel._blobContainerFolders && this.migrationStateModel._blobContainerFolders.includes(value) && !blobFolderErrorStrings.includes(value)) {
const selectedFolder = value;
this.migrationStateModel._databaseBackup.blobs[index].folderName = selectedFolder;
}