diff --git a/extensions/azurecore/src/azureResource/azure-resource.d.ts b/extensions/azurecore/src/azureResource/azure-resource.d.ts index a7f89d76c4..5c4d766a73 100644 --- a/extensions/azurecore/src/azureResource/azure-resource.d.ts +++ b/extensions/azurecore/src/azureResource/azure-resource.d.ts @@ -69,6 +69,12 @@ declare module 'azureResource' { } export interface AzureResourceResourceGroup extends AzureResource { + location?: string; + managedBy?: string; + properties?: { + provisioningState?: string + }; + type?: string; } export interface AzureLocation { diff --git a/extensions/azurecore/src/azureResource/utils.ts b/extensions/azurecore/src/azureResource/utils.ts index aeb48ace86..1ecf91165a 100644 --- a/extensions/azurecore/src/azureResource/utils.ts +++ b/extensions/azurecore/src/azureResource/utils.ts @@ -7,7 +7,7 @@ import { ResourceGraphClient } from '@azure/arm-resourcegraph'; import { TokenCredentials } from '@azure/ms-rest-js'; import axios, { AxiosRequestConfig } from 'axios'; import * as azdata from 'azdata'; -import { AzureRestResponse, GetResourceGroupsResult, GetSubscriptionsResult, ResourceQueryResult, GetBlobContainersResult, GetFileSharesResult, HttpRequestMethod, GetLocationsResult, GetManagedDatabasesResult } from 'azurecore'; +import { AzureRestResponse, GetResourceGroupsResult, GetSubscriptionsResult, ResourceQueryResult, GetBlobContainersResult, GetFileSharesResult, HttpRequestMethod, GetLocationsResult, GetManagedDatabasesResult, CreateResourceGroupResult } from 'azurecore'; import { azureResource } from 'azureResource'; import { EOL } from 'os'; import * as nls from 'vscode-nls'; @@ -455,3 +455,15 @@ export async function getFileShares(account: azdata.Account, subscription: azure errors: response.errors ? response.errors : [] }; } + +export async function createResourceGroup(account: azdata.Account, subscription: azureResource.AzureResourceSubscription, resourceGroupName: string, location: string, ignoreErrors: boolean): Promise { + const path = `/subscriptions/${subscription.id}/resourcegroups/${resourceGroupName}?api-version=2021-04-01`; + const requestBody = { + location: location + }; + const response = await makeHttpRequest(account, subscription, path, HttpRequestMethod.PUT, requestBody, ignoreErrors); + return { + resourceGroup: response?.response?.data, + errors: response.errors ? response.errors : [] + }; +} diff --git a/extensions/azurecore/src/azurecore.d.ts b/extensions/azurecore/src/azurecore.d.ts index 1e54f65a84..3e6a5e162d 100644 --- a/extensions/azurecore/src/azurecore.d.ts +++ b/extensions/azurecore/src/azurecore.d.ts @@ -258,6 +258,7 @@ declare module 'azurecore' { getStorageAccounts(account: azdata.Account, subscriptions: azureResource.AzureResourceSubscription[], ignoreErrors?: boolean): Promise; getBlobContainers(account: azdata.Account, subscription: azureResource.AzureResourceSubscription, storageAccount: azureResource.AzureGraphResource, ignoreErrors?: boolean): Promise; getFileShares(account: azdata.Account, subscription: azureResource.AzureResourceSubscription, storageAccount: azureResource.AzureGraphResource, ignoreErrors?: boolean): Promise; + createResourceGroup(account: azdata.Account, subscription: azureResource.AzureResourceSubscription, resourceGroupName: string, location: string, ignoreErrors?: boolean): Promise; /** * Makes Azure REST requests to create, retrieve, update or delete access to azure service's resources. * For reference to different service URLs, See https://docs.microsoft.com/rest/api/?view=Azure @@ -290,6 +291,7 @@ declare module 'azurecore' { export type GetStorageAccountResult = { resources: azureResource.AzureGraphResource[], errors: Error[] }; export type GetBlobContainersResult = { blobContainers: azureResource.BlobContainer[], errors: Error[] }; export type GetFileSharesResult = { fileShares: azureResource.FileShare[], errors: Error[] }; + export type CreateResourceGroupResult = { resourceGroup: azureResource.AzureResourceResourceGroup, errors: Error[] }; export type ResourceQueryResult = { resources: T[], errors: Error[] }; export type AzureRestResponse = { response: any, errors: Error[] }; } diff --git a/extensions/azurecore/src/extension.ts b/extensions/azurecore/src/extension.ts index 769f16f1d2..c03447998b 100644 --- a/extensions/azurecore/src/extension.ts +++ b/extensions/azurecore/src/extension.ts @@ -207,6 +207,13 @@ export async function activate(context: vscode.ExtensionContext): Promise { return azureResourceUtils.getFileShares(account, subscription, storageAccount, ignoreErrors); }, + createResourceGroup(account: azdata.Account, + subscription: azureResource.AzureResourceSubscription, + resourceGroupName: string, + location: string, + ignoreErrors: boolean): Promise { + return azureResourceUtils.createResourceGroup(account, subscription, resourceGroupName, location, ignoreErrors); + }, makeAzureRestRequest(account: azdata.Account, subscription: azureResource.AzureResourceSubscription, path: string, diff --git a/extensions/machine-learning/src/test/stubs.ts b/extensions/machine-learning/src/test/stubs.ts index d68daa237a..81f824b684 100644 --- a/extensions/machine-learning/src/test/stubs.ts +++ b/extensions/machine-learning/src/test/stubs.ts @@ -8,6 +8,9 @@ import * as azurecore from 'azurecore'; import { azureResource } from 'azureResource'; export class AzurecoreApiStub implements azurecore.IExtension { + createResourceGroup(_account: azdata.Account, _subscription: azureResource.AzureResourceSubscription, _resourceGroupName: string, _location: string, _ignoreErrors?: boolean): Promise { + throw new Error('Method not implemented.'); + } getManagedDatabases(_account: azdata.Account, _subscription: azureResource.AzureResourceSubscription, _managedInstance: azureResource.AzureGraphResource, _ignoreErrors?: boolean): Promise { throw new Error('Method not implemented.'); } diff --git a/extensions/sql-migration/src/api/azure.ts b/extensions/sql-migration/src/api/azure.ts index 4f1ecbdaec..0a85988c86 100644 --- a/extensions/sql-migration/src/api/azure.ts +++ b/extensions/sql-migration/src/api/azure.ts @@ -53,6 +53,12 @@ export async function getResourceGroups(account: azdata.Account, subscription: S return result.resourceGroups; } +export async function createResourceGroup(account: azdata.Account, subscription: Subscription, resourceGroupName: string, location: string): Promise { + const api = await getAzureCoreAPI(); + const result = await api.createResourceGroup(account, subscription, resourceGroupName, location, false); + return result.resourceGroup; +} + export type SqlManagedInstance = azureResource.AzureSqlManagedInstance; export async function getAvailableManagedInstanceProducts(account: azdata.Account, subscription: Subscription): Promise { const api = await getAzureCoreAPI(); diff --git a/extensions/sql-migration/src/constants/strings.ts b/extensions/sql-migration/src/constants/strings.ts index 2b367a16b8..914c6debb4 100644 --- a/extensions/sql-migration/src/constants/strings.ts +++ b/extensions/sql-migration/src/constants/strings.ts @@ -210,9 +210,18 @@ export const MANAGED_INSTANCE = localize('sql.migration.managed.instance', "Azur export const NO_MANAGED_INSTANCE_FOUND = localize('sql.migration.no.managedInstance.found', "No managed instance found"); export const NO_VIRTUAL_MACHINE_FOUND = localize('sql.migration.no.virtualMachine.found', "No virtual machine found"); export const TARGET_SELECTION_PAGE_TITLE = localize('sql.migration.target.page.title', "Choose the target Azure SQL"); +export const RESOURCE_GROUP_DESCRIPTION = localize('sql.migration.resource.group.description', "A resource group is a container that holds related resources for an Azure solution"); +export const OK = localize('sql.migration.ok', "OK"); +export function NEW_RESOURCE_GROUP(resourceGroupName: string): string { + return localize('sql.migration.new.resource.group', "(new) {0}", resourceGroupName); +} export const TEST_CONNECTION = localize('sql.migration.test.connection', "Test connection"); export const DATA_MIGRATION_SERVICE_CREATED_SUCCESSFULLY = localize('sql.migration.database.migration.service.created.successfully', "Database migration service has been created successfully"); export const DMS_PROVISIONING_FAILED = localize('sql.migration.dms.provision.failed', "Database migration service has failed to provision. Please try again after some time."); +export const APPLY = localize('sql.migration.apply', "Apply"); +export const CREATING_RESOURCE_GROUP = localize('sql.migration.creating.rg.loading', "Creating resource group"); +export const RESOURCE_GROUP_CREATED = localize('sql.migration.rg.created', "Resource group created"); +export const NAME_OF_NEW_RESOURCE_GROUP = localize('sql.migration.name.of.new.rg', "Name of new Resource group"); // common strings export const LEARN_MORE = localize('sql.migration.learn.more', "Learn more"); export const SUBSCRIPTION = localize('sql.migration.subscription', "Subscription"); @@ -237,6 +246,7 @@ export const CLOSE = localize('sql.migration.close', "Close"); export const DATA_UPLOADED = localize('sql.migraiton.data.uploaded.size', "Data Uploaded/Size"); export const COPY_THROUGHPUT = localize('sql.migration.copy.throughput', "Copy Throughput (MBPS)"); + //Summary Page export const SUMMARY_PAGE_TITLE = localize('sql.migration.summary.page.title', "Summary"); export const AZURE_ACCOUNT_LINKED = localize('sql.migration.summary.azure.account.linked', "Azure account linked"); diff --git a/extensions/sql-migration/src/dialog/createResourceGroup/createResourceGroupDialog.ts b/extensions/sql-migration/src/dialog/createResourceGroup/createResourceGroupDialog.ts new file mode 100644 index 0000000000..1911df1320 --- /dev/null +++ b/extensions/sql-migration/src/dialog/createResourceGroup/createResourceGroupDialog.ts @@ -0,0 +1,192 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { azureResource } from 'azureResource'; +import { EventEmitter } from 'events'; +import { createResourceGroup } from '../../api/azure'; +import * as constants from '../../constants/strings'; + +export class CreateResourceGroupDialog { + private _dialogObject!: azdata.window.Dialog; + private _view!: azdata.ModelView; + private _creationEvent: EventEmitter = new EventEmitter; + + constructor(private _azureAccount: azdata.Account, private _subscription: azureResource.AzureResourceSubscription, private _location: string) { + this._dialogObject = azdata.window.createModelViewDialog( + '', + 'CreateResourceGroupDialog', + 550, + 'callout', + 'below', + false, + true, + { + height: 20, + width: 20, + xPos: 0, + yPos: 0 + } + ); + } + + async initialize(): Promise { + let tab = azdata.window.createTab('sql.migration.CreateResourceGroupDialog'); + await tab.registerContent(async (view: azdata.ModelView) => { + this._view = view; + + const resourceGroupDescription = view.modelBuilder.text().withProps({ + value: constants.RESOURCE_GROUP_DESCRIPTION, + CSSStyles: { + 'font-size': '13px', + 'margin-bottom': '10px' + } + }).component(); + const nameLabel = view.modelBuilder.text().withProps({ + value: constants.NAME, + CSSStyles: { + 'font-size': '13px', + 'font-weight': 'bold', + } + }).component(); + + const resourceGroupName = view.modelBuilder.inputBox().withProps({ + ariaLabel: constants.NAME_OF_NEW_RESOURCE_GROUP + }).withValidation(c => { + let valid = false; + if (c.value!.length > 0 && c.value!.length <= 90 && /^[-\w\._\(\)]+$/.test(c.value!)) { + valid = true; + } + okButton.enabled = valid; + return valid; + }).component(); + + resourceGroupName.onTextChanged(e => { + errorBox.updateCssStyles({ + 'display': 'none' + }); + }); + + const okButton = view.modelBuilder.button().withProps({ + label: constants.OK, + width: '80px', + enabled: false + }).component(); + + okButton.onDidClick(async e => { + errorBox.updateCssStyles({ + 'display': 'none' + }); + okButton.enabled = false; + cancelButton.enabled = false; + loading.loading = true; + try { + const resourceGroup = await createResourceGroup(this._azureAccount, this._subscription, resourceGroupName.value!, this._location); + this._creationEvent.emit('done', resourceGroup); + } catch (e) { + errorBox.updateCssStyles({ + 'display': 'inline' + }); + errorBox.text = e.toString(); + cancelButton.enabled = true; + resourceGroupName.validate(); + } finally { + loading.loading = false; + } + }); + + const cancelButton = view.modelBuilder.button().withProps({ + label: constants.CANCEL, + width: '80px' + }).component(); + + cancelButton.onDidClick(e => { + this._creationEvent.emit('done', undefined); + }); + + const loading = view.modelBuilder.loadingComponent().withProps({ + loading: false, + loadingText: constants.CREATING_RESOURCE_GROUP, + loadingCompletedText: constants.RESOURCE_GROUP_CREATED + }).component(); + + + const buttonContainer = view.modelBuilder.flexContainer().withProps({ + CSSStyles: { + 'margin-top': '5px' + } + }).component(); + + buttonContainer.addItem(okButton, { + flex: '0', + CSSStyles: { + 'width': '80px' + } + }); + + buttonContainer.addItem(cancelButton, { + flex: '0', + CSSStyles: { + 'margin-left': '8px', + 'width': '80px' + } + }); + + buttonContainer.addItem(loading, { + flex: '0', + CSSStyles: { + 'margin-left': '8px' + } + }); + + const errorBox = this._view.modelBuilder.infoBox().withProps({ + style: 'error', + text: '', + CSSStyles: { + 'display': 'none' + } + }).component(); + + const container = this._view.modelBuilder.flexContainer().withLayout({ + flexFlow: 'column' + }).withItems([ + resourceGroupDescription, + nameLabel, + resourceGroupName, + errorBox, + buttonContainer + ]).component(); + + const formBuilder = view.modelBuilder.formContainer().withFormItems( + [ + { + component: container + } + ], + { + horizontal: false + } + ); + const form = formBuilder.withLayout({ width: '100%' }).withProps({ + CSSStyles: { + 'padding': '0px !important' + } + }).component(); + return view.initializeModel(form).then(v => { + resourceGroupName.focus(); + }); + }); + this._dialogObject.okButton.label = constants.APPLY; + this._dialogObject.content = [tab]; + azdata.window.openDialog(this._dialogObject); + + return new Promise((resolve) => { + this._creationEvent.once('done', async (resourceGroup: azureResource.AzureResourceResourceGroup) => { + azdata.window.closeDialog(this._dialogObject); + resolve(resourceGroup); + }); + }); + } +} diff --git a/extensions/sql-migration/src/dialog/createSqlMigrationService/createSqlMigrationServiceDialog.ts b/extensions/sql-migration/src/dialog/createSqlMigrationService/createSqlMigrationServiceDialog.ts index 59ec033cfc..e9d81f0616 100644 --- a/extensions/sql-migration/src/dialog/createSqlMigrationService/createSqlMigrationServiceDialog.ts +++ b/extensions/sql-migration/src/dialog/createSqlMigrationService/createSqlMigrationServiceDialog.ts @@ -11,6 +11,7 @@ import * as constants from '../../constants/strings'; import * as os from 'os'; import { azureResource } from 'azureResource'; import { IconPathHelper } from '../../constants/iconPathHelper'; +import { CreateResourceGroupDialog } from '../createResourceGroup/createResourceGroupDialog'; import * as EventEmitter from 'events'; export class CreateSqlMigrationServiceDialog { @@ -22,6 +23,7 @@ export class CreateSqlMigrationServiceDialog { private migrationServiceLocation!: azdata.InputBoxComponent; private migrationServiceNameText!: azdata.InputBoxComponent; private _formSubmitButton!: azdata.ButtonComponent; + private _createResourceGroupLink!: azdata.HyperlinkComponent; private _statusLoadingComponent!: azdata.LoadingComponent; private _refreshLoadingComponent!: azdata.LoadingComponent; @@ -72,7 +74,7 @@ export class CreateSqlMigrationServiceDialog { const subscription = this._model._targetSubscription; - const resourceGroup = (this.migrationServiceResourceGroupDropdown.value as azdata.CategoryValue).displayName; + const resourceGroup = (this.migrationServiceResourceGroupDropdown.value as azdata.CategoryValue).name; const location = this._model._targetServerInstance.location; const serviceName = this.migrationServiceNameText.value; @@ -242,6 +244,29 @@ export class CreateSqlMigrationServiceDialog { } }).component(); + this._createResourceGroupLink = this._view.modelBuilder.hyperlink().withProps({ + label: constants.CREATE_NEW, + url: '' + }).component(); + + this._createResourceGroupLink.onDidClick(async e => { + const createResourceGroupDialog = new CreateResourceGroupDialog(this._model._azureAccount, this._model._targetSubscription, this._model._targetServerInstance.location); + const createdResourceGroup = await createResourceGroupDialog.initialize(); + if (createdResourceGroup) { + this.migrationServiceResourceGroupDropdown.loading = true; + (this.migrationServiceResourceGroupDropdown.values).unshift({ + displayName: constants.NEW_RESOURCE_GROUP(createdResourceGroup.name), + name: createdResourceGroup.name + }); + this.migrationServiceResourceGroupDropdown.value = { + displayName: createdResourceGroup.name, + name: createdResourceGroup.name + }; + this.migrationServiceResourceGroupDropdown.loading = false; + this.migrationServiceResourceGroupDropdown.focus(); + } + }); + this.migrationServiceNameText = this._view.modelBuilder.inputBox().component(); const locationDropdownLabel = this._view.modelBuilder.text().withProps({ @@ -279,6 +304,7 @@ export class CreateSqlMigrationServiceDialog { this.migrationServiceLocation, resourceGroupDropdownLabel, this.migrationServiceResourceGroupDropdown, + this._createResourceGroupLink, migrationServiceNameLabel, this.migrationServiceNameText, targetlabel, @@ -316,7 +342,7 @@ export class CreateSqlMigrationServiceDialog { this.migrationServiceResourceGroupDropdown.loading = true; try { this.migrationServiceResourceGroupDropdown.values = await this._model.getAzureResourceGroupDropdownValues(this._model._targetSubscription); - const selectedResourceGroupValue = this.migrationServiceResourceGroupDropdown.values.find(v => v.displayName.toLowerCase() === this._resourceGroupPreset.toLowerCase()); + const selectedResourceGroupValue = this.migrationServiceResourceGroupDropdown.values.find(v => v.name.toLowerCase() === this._resourceGroupPreset.toLowerCase()); this.migrationServiceResourceGroupDropdown.value = (selectedResourceGroupValue) ? selectedResourceGroupValue : this.migrationServiceResourceGroupDropdown.values[0]; } finally { this.migrationServiceResourceGroupDropdown.loading = false; @@ -467,7 +493,7 @@ export class CreateSqlMigrationServiceDialog { private async refreshStatus(): Promise { const subscription = this._model._targetSubscription; - const resourceGroup = (this.migrationServiceResourceGroupDropdown.value as azdata.CategoryValue).displayName; + const resourceGroup = (this.migrationServiceResourceGroupDropdown.value as azdata.CategoryValue).name; const location = this._model._targetServerInstance.location; const maxRetries = 5; @@ -513,7 +539,7 @@ export class CreateSqlMigrationServiceDialog { } private async refreshAuthTable(): Promise { const subscription = this._model._targetSubscription; - const resourceGroup = (this.migrationServiceResourceGroupDropdown.value as azdata.CategoryValue).displayName; + const resourceGroup = (this.migrationServiceResourceGroupDropdown.value as azdata.CategoryValue).name; const location = this._model._targetServerInstance.location; const keys = await getSqlMigrationServiceAuthKeys(this._model._azureAccount, subscription, resourceGroup, location, this._createdMigrationService!.name); @@ -589,6 +615,7 @@ export class CreateSqlMigrationServiceDialog { this._formSubmitButton.enabled = enable; this.migrationServiceResourceGroupDropdown.enabled = enable; this.migrationServiceNameText.enabled = enable; + this._createResourceGroupLink.enabled = enable; } }