From 3cf48fb207309480661ab59544e03ba0b214d252 Mon Sep 17 00:00:00 2001 From: Alan Ren Date: Tue, 7 Jul 2020 10:36:26 -0700 Subject: [PATCH] SQL Edge deployment using Azure IoT hub (#11202) * Azure IoT deployment type * more updates * organize fields * a few more improvements * resolve merge issues * new rg improvement * fix tests * comments 1 * comments 2 --- .../edge/deploy-sql-edge-azure.ipynb | 517 ++++++++++++++++++ extensions/asde-deployment/package.json | 134 ++++- extensions/asde-deployment/package.nls.json | 21 +- .../resource-deployment/src/interfaces.ts | 12 + .../src/localizedConstants.ts | 6 + .../src/ui/modelViewUtils.ts | 114 +++- .../accounts/browser/accounts.contribution.ts | 4 +- .../browser/accountManagementService.ts | 5 +- .../browser/accountManagementService.test.ts | 23 +- 9 files changed, 798 insertions(+), 38 deletions(-) create mode 100644 extensions/asde-deployment/notebooks/edge/deploy-sql-edge-azure.ipynb diff --git a/extensions/asde-deployment/notebooks/edge/deploy-sql-edge-azure.ipynb b/extensions/asde-deployment/notebooks/edge/deploy-sql-edge-azure.ipynb new file mode 100644 index 0000000000..7c1ec2ee80 --- /dev/null +++ b/extensions/asde-deployment/notebooks/edge/deploy-sql-edge-azure.ipynb @@ -0,0 +1,517 @@ +{ + "metadata": { + "kernelspec": { + "name": "python3", + "display_name": "Python 3" + }, + "language_info": { + "name": "python", + "version": "3.6.6", + "mimetype": "text/x-python", + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "pygments_lexer": "ipython3", + "nbconvert_exporter": "python", + "file_extension": ".py" + } + }, + "nbformat_minor": 2, + "nbformat": 4, + "cells": [ + { + "cell_type": "markdown", + "source": [ + "![Microsoft](https://raw.githubusercontent.com/microsoft/azuredatastudio/main/extensions/resource-deployment/images/microsoft-small-logo.png)\n", + "## Run Azure SQL Database Edge image on a device\n", + "This notebook deploy the Azure SQL Database Edge module on a device and connect to it in Azure Data Studio\n", + "\n", + "### Dependencies\n", + "- Azure CLI. For more information, see [Azure CLI Installation](https://docs.microsoft.com/cli/azure/install-azure-cli?view=azure-cli-latest).\n", + "\n", + "Please press the \"Run all\" button to run the notebook" + ], + "metadata": { + "azdata_cell_guid": "15b8cfc7-dd7f-4db8-9a3c-2151932fe7b5" + } + }, + { + "cell_type": "markdown", + "source": [ + "### Check dependencies" + ], + "metadata": { + "azdata_cell_guid": "f78f4ff3-d4c9-4c3e-853f-4add05061eb0" + } + }, + { + "cell_type": "code", + "source": [ + "import pandas,sys,os,json,html,getpass,time,ntpath,uuid\n", + "pandas_version = pandas.__version__.split('.')\n", + "pandas_major = int(pandas_version[0])\n", + "pandas_minor = int(pandas_version[1])\n", + "pandas_patch = int(pandas_version[2])\n", + "if not (pandas_major > 0 or (pandas_major == 0 and pandas_minor > 24) or (pandas_major == 0 and pandas_minor == 24 and pandas_patch >= 2)):\n", + " sys.exit('Please upgrade the Notebook dependency before you can proceed, you can do it by running the \"Reinstall Notebook dependencies\" command in command palette (View menu -> Command Palette…).')\n", + "\n", + "def run_command(command:str, displayCommand:str = \"\", returnObject:bool = False):\n", + " print(\"Executing: \" + (displayCommand if displayCommand != \"\" else command))\n", + " if returnObject:\n", + " output = os.popen(command).read()\n", + " print(f'Command successfully executed')\n", + " return json.loads(''.join(output))\n", + " else:\n", + " !{command}\n", + " if _exit_code != 0:\n", + " sys.exit(f'Command execution failed with exit code: {str(_exit_code)}.\\n')\n", + " else:\n", + " print(f'Command successfully executed')\n", + "\n", + "run_command(command='az --version')" + ], + "metadata": { + "azdata_cell_guid": "70b9744f-eb59-44e8-9b35-db590ac4651d", + "tags": [ + "hide_input" + ] + }, + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "source": [ + "### Install Azure IoT extension for Azure CLI" + ], + "metadata": { + "azdata_cell_guid": "a7f15c68-1725-4caa-b4f7-ddc2b4934883" + } + }, + { + "cell_type": "code", + "source": [ + "run_command('az extension add --name azure-cli-iot-ext')" + ], + "metadata": { + "azdata_cell_guid": "55bb2f96-6f7f-4aa0-9daf-d0f7f9d9243c", + "tags": [ + "hide_input" + ] + }, + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "source": [ + "### Required information" + ], + "metadata": { + "azdata_cell_guid": "b5dc5586-06e8-44d9-8bc3-2861d510efe5" + } + }, + { + "cell_type": "code", + "source": [ + "azure_subscription_id = os.environ[\"AZDATA_NB_VAR_ASDE_SUBSCRIPTIONID\"]\n", + "azure_resource_group = os.environ[\"AZDATA_NB_VAR_ASDE_RESOURCEGROUP\"]\n", + "azure_location = os.environ[\"AZDATA_NB_VAR_ASDE_AZURE_LOCATION_TEXT\"]\n", + "sa_password = os.environ[\"AZDATA_NB_VAR_SA_PASSWORD\"]\n", + "vm_admin = os.environ[\"AZDATA_NB_VAR_ASDE_VM_ADMIN\"]\n", + "vm_password = os.environ[\"AZDATA_NB_VAR_ASDE_VM_PASSWORD\"]\n", + "iot_device_id = os.environ[\"AZDATA_NB_VAR_ASDE_DEVICE_ID\"]\n", + "dacpac_path = os.environ[\"AZDATA_NB_VAR_ASDE_DACPAC_PATH\"]\n", + "sql_port = os.environ[\"AZDATA_NB_VAR_ASDE_SQL_PORT\"]\n", + "new_rg_flag = os.environ[\"AZDATA_NB_VAR_ASDE_NEW_RESOURCEGROUP\"]\n", + "new_rg_name = os.environ[\"AZDATA_NB_VAR_ASDE_NEW_RESOURCEGROUP_NAME\"]\n", + "\n", + "if new_rg_flag == 'true':\n", + " azure_resource_group = new_rg_name\n", + "print(f'Subscription: {azure_subscription_id}')\n", + "print(f'Resource group: {azure_resource_group}')\n", + "print(f'Location: {azure_location}')\n", + "print(f'VM name: {iot_device_id}')\n", + "print(f'VM admin username: {vm_admin}')\n", + "print(f'VM admin password: ******')\n", + "print(f'SQL Server port: {sql_port}')\n", + "print(f'SQL Server sa password: ******')\n", + "print(f'Dacpac path: {dacpac_path}')" + ], + "metadata": { + "azdata_cell_guid": "dde9388b-f623-4d62-bb74-36a05f5d2ea3", + "tags": [ + "hide_input" + ] + }, + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "source": [ + "### Default settings" + ], + "metadata": { + "azdata_cell_guid": "2a5755eb-85a7-4237-8d87-04cdab13cf40" + } + }, + { + "cell_type": "code", + "source": [ + "network_security_group = 'asde_nsg'\n", + "public_ip_address_name = 'asde_public_ip'\n", + "iot_hub_name = 'asdeiothub'\n", + "iot_hub_sku = 'S1'\n", + "iot_hub_units = 4\n", + "vm_size = 'Standard_DS3_v2'\n", + "vnet_name = 'asde_network'\n", + "subnet_name = 'asde_subnet'\n", + "subnet_address_prefix = '10.0.0.0/24'\n", + "vnet_address_prefix = '10.0.0.0/16'\n", + "azure_storage_account = azure_resource_group.lower()\n", + "storage_account_container = 'sqldatabasedacpac'\n", + "sql_lcid = '1033'\n", + "sql_collation = 'SQL_Latin1_General_CP1_CI_AS'" + ], + "metadata": { + "azdata_cell_guid": "19ebeaf4-94c9-4d2b-bd9f-e3c6bf7f2dda", + "tags": [ + "hide_input" + ] + }, + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "source": [ + "### Login to Azure" + ], + "metadata": { + "azdata_cell_guid": "84f57c09-5772-4f7a-a270-4039b8d5b081" + } + }, + { + "cell_type": "code", + "source": [ + "run_command('az login')" + ], + "metadata": { + "azdata_cell_guid": "f9e8ddee-aefa-4951-b767-b318d941d2cd", + "tags": [ + "hide_input" + ] + }, + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "source": [ + "### Set active Azure subscription" + ], + "metadata": { + "azdata_cell_guid": "59249fa6-f76c-4e5d-bee7-a9ebef6f873e" + } + }, + { + "cell_type": "code", + "source": [ + "if azure_subscription_id != \"\":\n", + " run_command(f'az account set --subscription {azure_subscription_id}')\n", + "else:\n", + " print('Using the default Azure subscription', {azure_subscription_id})\n", + "run_command(f'az account show')" + ], + "metadata": { + "azdata_cell_guid": "6e085676-2cc5-4af8-819c-fa210244e6c3", + "tags": [ + "hide_input" + ] + }, + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "source": [ + "### Create resource group" + ], + "metadata": { + "azdata_cell_guid": "67dacbaa-92f4-4d06-90bb-8974964852aa" + } + }, + { + "cell_type": "code", + "source": [ + "rg_exists = run_command(f'az group exists --name {azure_resource_group}', returnObject=True)\n", + "\n", + "if rg_exists:\n", + " print(f'resource group \\\"{azure_resource_group}\\\" already exists.')\n", + "else:\n", + " run_command(f'az group create --location {azure_location} --name {azure_resource_group}')" + ], + "metadata": { + "azdata_cell_guid": "f29b439e-cf05-4c35-aa47-1482ccd653bf", + "tags": [ + "hide_input" + ] + }, + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "source": [ + "### Create storage account and storage account container, then upload the dacpac" + ], + "metadata": { + "azdata_cell_guid": "90ec2b26-0c4a-4aa4-b397-f16b09b454ea" + } + }, + { + "cell_type": "code", + "source": [ + "if dacpac_path == \"\":\n", + " print(f'Dacpac zip file not provided')\n", + " blob_sas = ''\n", + "else: \n", + " dacpac_name = ntpath.basename(dacpac_path)\n", + " storage_accounts = run_command(f'az storage account list --resource-group {azure_resource_group} --subscription {azure_subscription_id}', returnObject=True)\n", + " storage_accounts = [storage_account for storage_account in storage_accounts if storage_account['name'] == azure_storage_account]\n", + " if len(storage_accounts) == 0:\n", + " run_command(f'az storage account create -n {azure_storage_account} -g {azure_resource_group} -l {azure_location} --sku Standard_LRS --kind Storage')\n", + " else:\n", + " print(f'storage account \\\"{azure_storage_account}\\\" already exists.')\n", + "\n", + " storage_account_key = run_command(f'az storage account keys list --account-name {azure_storage_account} --resource-group {azure_resource_group}', returnObject=True)[0]['value']\n", + " container_exists = run_command(f'az storage container exists --name {storage_account_container} --account-key {storage_account_key} --account-name {azure_storage_account} --auth-mode key --output json', returnObject=True)['exists']\n", + " if container_exists:\n", + " print(f'storage account container \\\"{storage_account_container}\\\" already exists.')\n", + " else:\n", + " run_command(f'az storage container create --name {storage_account_container} --account-key {storage_account_key} --account-name {azure_storage_account} --auth-mode key')\n", + "\n", + " blob_exists = run_command(f'az storage blob exists --container-name {storage_account_container} --name \\\"{dacpac_name}\\\" --account-key {storage_account_key} --account-name {azure_storage_account} --auth-mode key', returnObject=True)['exists']\n", + " if blob_exists:\n", + " print(f'blob \\\"{dacpac_name}\\\" already exists.')\n", + " else:\n", + " run_command(f'az storage blob upload --account-name {azure_storage_account} --container-name {storage_account_container} --name {dacpac_name} --file \\\"{dacpac_path}\\\" --account-key {storage_account_key} --auth-mode key')\n", + " now = time.localtime()\n", + " expiry = f'{(now.tm_year + 1)}-{now.tm_mon}-{now.tm_mday}'\n", + " blob_sas = run_command(f'az storage blob generate-sas --container-name {storage_account_container} --name \\\"{dacpac_name}\\\" --account-name {azure_storage_account} --account-key {storage_account_key} --auth-mode key --full-uri --https-only --permissions r --expiry {expiry}', returnObject=True)" + ], + "metadata": { + "azdata_cell_guid": "7ab2b3ec-0832-40b3-98c0-4aa87320e7ce", + "tags": [ + "hide_input" + ] + }, + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "source": [ + "### Create network security group" + ], + "metadata": { + "azdata_cell_guid": "b308771b-138a-40ce-a9d3-1d15094d537b" + } + }, + { + "cell_type": "code", + "source": [ + "nsg_list = run_command(f'az network nsg list --resource-group {azure_resource_group}', returnObject=True)\n", + "nsg_list = [nsg for nsg in nsg_list if nsg['name'] == network_security_group]\n", + "if len(nsg_list) == 0:\n", + " run_command(f'az network nsg create --name {network_security_group} --resource-group {azure_resource_group} --location {azure_location}')\n", + " run_command(f'az network nsg rule create --name \\\"SQL\\\" --nsg-name {network_security_group} --priority 100 --resource-group {azure_resource_group} --access Allow --description \\\"Allow SQL\\\" --destination-address-prefixes \\\"*\\\" --destination-port-ranges {sql_port} --direction Inbound --source-address-prefixes Internet --protocol Tcp')\n", + "else:\n", + " print(f'Network security group \\\"{network_security_group}\\\" already exists.')" + ], + "metadata": { + "azdata_cell_guid": "99cbb95c-b109-4b2e-909b-ff71a62754fb", + "tags": [ + "hide_input" + ] + }, + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "source": [ + "### Create an Edge enabled VM as an Edge device" + ], + "metadata": { + "azdata_cell_guid": "41b10249-cd40-4053-b1b0-b02f562789f7" + } + }, + { + "cell_type": "code", + "source": [ + "vm_list = run_command(f'az vm list --resource-group {azure_resource_group}', returnObject=True)\n", + "vm_list = [vm for vm in vm_list if vm['name'] == iot_device_id]\n", + "if len(vm_list) == 0:\n", + " vm_image = run_command(f'az vm image list --all --location {azure_location} --offer iot_edge_vm_ubuntu --publisher microsoft_iot_edge --sku ubuntu_1604_edgeruntimeonly', returnObject=True)\n", + " image_urn = vm_image[0]['urn']\n", + " run_command(f'az vm image accept-terms --urn {image_urn}')\n", + " vm_password_placeholder = ''\n", + " create_vm_command_template = f'az vm create --name {iot_device_id} --resource-group {azure_resource_group} --admin-username {vm_admin} --admin-password {vm_password_placeholder} --authentication-type password --image {image_urn} --location {azure_location} --nsg {network_security_group} --public-ip-address \\\"{public_ip_address_name}\\\" --public-ip-address-allocation static --public-ip-sku Standard --size {vm_size} --subnet {subnet_name} --subnet-address-prefix \\\"{subnet_address_prefix}\\\" --vnet-name {vnet_name} --vnet-address-prefix \\\"{vnet_address_prefix}\\\"'\n", + " run_command(create_vm_command_template.replace(vm_password_placeholder, vm_password), displayCommand=create_vm_command_template.replace(vm_password_placeholder, '******'))\n", + "else:\n", + " print(f'VM \\\"{iot_device_id}\\\" already exists, skipping the vm creation.')\n", + "ip_address = run_command(f'az vm show -d -g {azure_resource_group} -n {iot_device_id} --query publicIps', returnObject=True)" + ], + "metadata": { + "azdata_cell_guid": "157fc38f-cf2a-40c6-9c9e-88f45cc5c62f", + "tags": [ + "hide_input" + ] + }, + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "source": [ + "### Create IoT hub" + ], + "metadata": { + "azdata_cell_guid": "e37a04c3-515d-4cb7-99b2-f8bc6167510e" + } + }, + { + "cell_type": "code", + "source": [ + "hub_list = run_command(f'az iot hub list --resource-group {azure_resource_group}', returnObject=True)\n", + "hub_list = [hub for hub in hub_list if hub['name'] == iot_hub_name]\n", + "if len(hub_list) == 0:\n", + " run_command(f'az iot hub create --name {iot_hub_name} --resource-group {azure_resource_group} --location {azure_location} --sku {iot_hub_sku} --unit {iot_hub_units}')\n", + "else:\n", + " print(f'IoT hub \\\"{iot_hub_name}\\\" already exists')" + ], + "metadata": { + "azdata_cell_guid": "f9f5e4ec-82a5-45df-a408-ddb0fb21847c", + "tags": [ + "hide_input" + ] + }, + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "source": [ + "### Add the Edge device to the IoT hub" + ], + "metadata": { + "azdata_cell_guid": "fbc5f4ac-dfe0-4543-ace1-49b796251910" + } + }, + { + "cell_type": "code", + "source": [ + "device_list = run_command(f'az iot hub device-identity list --edge-enabled true --hub-name {iot_hub_name} --resource-group {azure_resource_group}', returnObject=True)\n", + "device_list = [device for device in device_list if device['deviceId'] == iot_device_id]\n", + "if len(device_list) == 0:\n", + " run_command(f'az iot hub device-identity create --device-id {iot_device_id} --hub-name {iot_hub_name} --resource-group {azure_resource_group} --edge-enabled true')\n", + "else:\n", + " print(f'Edge device \\\"{iot_device_id}\\\" already exists.')" + ], + "metadata": { + "azdata_cell_guid": "c183c3e3-8699-4f29-993b-07bf848336e3", + "tags": [ + "hide_input" + ] + }, + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "source": [ + "### Configure Edge on the device" + ], + "metadata": { + "azdata_cell_guid": "069db017-9169-499a-839b-9cd73ea7d01e" + } + }, + { + "cell_type": "code", + "source": [ + "connection_string = run_command(f'az iot hub device-identity show-connection-string --device-id {iot_device_id} --hub-name {iot_hub_name} --resource-group {azure_resource_group}', returnObject=True)\n", + "connection_string = connection_string['connectionString']\n", + "script = f'/etc/iotedge/configedge.sh \\'{connection_string}\\''\n", + "run_command(f'az vm run-command invoke -g {azure_resource_group} -n {iot_device_id} --command-id RunShellScript --script \\\"{script}\\\"')" + ], + "metadata": { + "azdata_cell_guid": "9ec1e31a-79aa-49f4-a0e5-16f8d7c2dd21", + "tags": [ + "hide_input" + ] + }, + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "source": [ + "### Deploy Azure SQL Database Edge to the device" + ], + "metadata": { + "azdata_cell_guid": "ec46957f-0795-4c75-804d-f8a7ecb26382" + } + }, + { + "cell_type": "code", + "source": [ + "manifest = '{\\\"modulesContent\\\":{\\\"$edgeAgent\\\":{\\\"properties.desired\\\":{\\\"modules\\\":{\\\"AzureSQLEdge\\\":{\\\"settings\\\":{\\\"image\\\":\\\"mcr.microsoft.com/azure-sql-edge-developer\\\",\\\"createOptions\\\":\\\"{\\\\\\\"HostConfig\\\\\\\":{\\\\\\\"CapAdd\\\\\\\":[\\\\\\\"SYS_PTRACE\\\\\\\"],\\\\\\\"Binds\\\\\\\":[\\\\\\\"sqlvolume:/sqlvolume\\\\\\\"],\\\\\\\"PortBindings\\\\\\\":{\\\\\\\"1433/tcp\\\\\\\":[{\\\\\\\"HostPort\\\\\\\":\\\\\\\"\\\\\\\"}]},\\\\\\\"Mounts\\\\\\\":[{\\\\\\\"Type\\\\\\\":\\\\\\\"volume\\\\\\\",\\\\\\\"Source\\\\\\\":\\\\\\\"sqlvolume\\\\\\\",\\\\\\\"Target\\\\\\\":\\\\\\\"/var/opt/mssql\\\\\\\"}]},\\\\\\\"User\\\\\\\":\\\\\\\"0:0\\\\\\\",\\\\\\\"Env\\\\\\\":[\\\\\\\"MSSQL_AGENT_ENABLED=TRUE\\\\\\\",\\\\\\\"ClientTransportType=AMQP_TCP_Only\\\\\\\",\\\\\\\"MSSQL_PID=Developer\\\\\\\"]}\\\"},\\\"type\\\":\\\"docker\\\",\\\"version\\\":\\\"1.0\\\",\\\"env\\\":{\\\"ACCEPT_EULA\\\":{\\\"value\\\":\\\"Y\\\"},\\\"SA_PASSWORD\\\":{\\\"value\\\":\\\"\\\"},\\\"MSSQL_LCID\\\":{\\\"value\\\":\\\"\\\"},\\\"MSSQL_COLLATION\\\":{\\\"value\\\":\\\"\\\"}},\\\"status\\\":\\\"running\\\",\\\"restartPolicy\\\":\\\"always\\\"}},\\\"runtime\\\":{\\\"settings\\\":{\\\"minDockerVersion\\\":\\\"v1.25\\\"},\\\"type\\\":\\\"docker\\\"},\\\"schemaVersion\\\":\\\"1.0\\\",\\\"systemModules\\\":{\\\"edgeAgent\\\":{\\\"settings\\\":{\\\"image\\\":\\\"mcr.microsoft.com/azureiotedge-agent:1.0\\\",\\\"createOptions\\\":\\\"\\\"},\\\"type\\\":\\\"docker\\\"},\\\"edgeHub\\\":{\\\"settings\\\":{\\\"image\\\":\\\"mcr.microsoft.com/azureiotedge-hub:1.0\\\",\\\"createOptions\\\":\\\"{\\\\\\\"HostConfig\\\\\\\":{\\\\\\\"PortBindings\\\\\\\":{\\\\\\\"443/tcp\\\\\\\":[{\\\\\\\"HostPort\\\\\\\":\\\\\\\"443\\\\\\\"}],\\\\\\\"5671/tcp\\\\\\\":[{\\\\\\\"HostPort\\\\\\\":\\\\\\\"5671\\\\\\\"}],\\\\\\\"8883/tcp\\\\\\\":[{\\\\\\\"HostPort\\\\\\\":\\\\\\\"8883\\\\\\\"}]}}}\\\"},\\\"type\\\":\\\"docker\\\",\\\"status\\\":\\\"running\\\",\\\"restartPolicy\\\":\\\"always\\\"}}}},\\\"$edgeHub\\\":{\\\"properties.desired\\\":{\\\"routes\\\":{},\\\"schemaVersion\\\":\\\"1.0\\\",\\\"storeAndForwardConfiguration\\\":{\\\"timeToLiveSecs\\\":7200}}},\\\"AzureSQLEdge\\\":{\\\"properties.desired\\\":{\\\"SqlPackage\\\":\\\"\\\",\\\"ASAJobInfo\\\":\\\"\\\"}}}}'\n", + "manifest = manifest.replace('', blob_sas).replace('',sa_password).replace('',sql_lcid).replace('',sql_port).replace('',sql_collation)\n", + "file_name = f'{uuid.uuid4().hex}.json'\n", + "manifest_file = open(file_name, 'w')\n", + "manifest_file.write(manifest)\n", + "manifest_file.close()\n", + "run_command(f'az iot edge set-modules --device-id \\\"{iot_device_id}\\\" --hub-name \\\"{iot_hub_name}\\\" --content \\\"{file_name}\\\" --resource-group {azure_resource_group}')\n", + "os.remove(file_name)" + ], + "metadata": { + "azdata_cell_guid": "81a86ff6-5a83-48be-8be7-654d152eea89", + "tags": [ + "hide_input" + ] + }, + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "source": [ + "### **Connect to SQL Server instance in Azure Data Studio**\n", + "Click the link below to connect to the SQL Server, it might take a couple minutes for the service to start." + ], + "metadata": { + "azdata_cell_guid": "3bdfa537-a749-45c4-b219-57d296c22739" + } + }, + { + "cell_type": "code", + "source": [ + "from IPython.display import *\n", + "connectionParameter = '{\"serverName\":\"' + f'{ip_address},{sql_port}' + '\",\"providerName\":\"MSSQL\",\"authenticationType\":\"SqlLogin\",\"userName\": \"sa\",\"password\":' + json.dumps(sa_password) + '}'\n", + "display(HTML('
Click here to connect to SQL Server
'))\n", + "display(HTML('
NOTE: The SQL Server password is included in this link, you may want to clear the results of this code cell before saving the notebook.'))" + ], + "metadata": { + "azdata_cell_guid": "8bc29cce-96a7-4a78-89af-5c73a6431c24", + "tags": [ + "hide_input" + ] + }, + "outputs": [], + "execution_count": null + } + ] +} \ No newline at end of file diff --git a/extensions/asde-deployment/package.json b/extensions/asde-deployment/package.json index 3fb7578a79..e504b10659 100644 --- a/extensions/asde-deployment/package.json +++ b/extensions/asde-deployment/package.json @@ -41,6 +41,10 @@ { "name": "remote", "displayName": "%sql-edge-remote-display-name%" + }, + { + "name": "azure", + "displayName": "%sql-edge-azure-display-name%" } ] } @@ -48,7 +52,7 @@ "providers": [ { "dialog": { - "notebook": "%sql-edge-local-notebook%", + "notebook": "./notebooks/edge/deploy-sql-edge-local.ipynb", "title": "%sql-edge-local-title%", "name": "sql-edge-local-dialog", "tabs": [ @@ -141,7 +145,7 @@ }, { "dialog": { - "notebook": "%sql-edge-remote-notebook%", + "notebook": "./notebooks/edge/deploy-sql-edge-remote.ipynb", "title": "%sql-edge-remote-title%", "name": "sql-edge-remote-dialog", "tabs": [ @@ -255,6 +259,132 @@ }, "requiredTools": [], "when": "type=remote" + }, + { + "dialog": { + "notebook": "./notebooks/edge/deploy-sql-edge-azure.ipynb", + "title": "%sql-edge-azure-title%", + "name": "sql-edge-azure-dialog", + "tabs": [ + { + "title": "", + "sections": [ + { + "title": "%azure-info-section-title%", + "collapsible": true, + "fields": [ + { + "subscriptionVariableName": "AZDATA_NB_VAR_ASDE_SUBSCRIPTIONID", + "resourceGroupVariableName": "AZDATA_NB_VAR_ASDE_RESOURCEGROUP", + "type": "azure_account", + "required": true, + "allowNewResourceGroup": true, + "newResourceGroupFlagVariableName": "AZDATA_NB_VAR_ASDE_NEW_RESOURCEGROUP", + "newResourceGroupNameVariableName": "AZDATA_NB_VAR_ASDE_NEW_RESOURCEGROUP_NAME" + }, + { + "type": "azure_locations", + "label": "%azure_location%", + "defaultValue": "westus", + "required": true, + "locationVariableName": "AZDATA_NB_VAR_ASDE_AZURE_LOCATION", + "displayLocationVariableName": "AZDATA_NB_VAR_ASDE_AZURE_LOCATION_TEXT", + "locations": [ + "australiaeast", + "australiasoutheast", + "brazilsouth", + "canadacentral", + "canadaeast", + "centralindia", + "centralus", + "eastasia", + "eastus", + "eastus2", + "francecentral", + "japaneast", + "japanwest", + "koreacentral", + "koreasouth", + "northcentralus", + "northeurope", + "southcentralus", + "southindia", + "southeastasia", + "uksouth", + "ukwest", + "westcentralus", + "westeurope", + "westus", + "westus2" + ] + }, + { + "label": "%iot_device_id%", + "variableName": "AZDATA_NB_VAR_ASDE_DEVICE_ID", + "type": "text", + "required": true + }, + { + "label": "%vm_admin%", + "variableName": "AZDATA_NB_VAR_ASDE_VM_ADMIN", + "type": "text", + "required": true + }, + { + "label": "%vm_password%", + "variableName": "AZDATA_NB_VAR_ASDE_VM_PASSWORD", + "type": "password", + "confirmationRequired": true, + "confirmationLabel": "%vm_password_confirm%", + "required": true + } + ] + }, + { + "title": "%sqlserver-info-section-title%", + "collapsible": true, + "fields": [ + { + "label": "%docker-sql-password-field%", + "variableName": "AZDATA_NB_VAR_SA_PASSWORD", + "type": "sql_password", + "userName": "sa", + "confirmationRequired": true, + "confirmationLabel": "%docker-confirm-sql-password-field%", + "defaultValue": "", + "required": true + }, + { + "label": "%docker-sql-port-field%", + "variableName": "AZDATA_NB_VAR_ASDE_SQL_PORT", + "type": "number", + "defaultValue": 31433, + "required": true + }, + { + "label": "%dacpac_path%", + "variableName": "AZDATA_NB_VAR_ASDE_DACPAC_PATH", + "type": "file_picker", + "required": false, + "filter": { + "displayName": "%dacpac-zip-files%", + "fileTypes": [ + "zip" + ] + } + } + ] + } + ] + } + ] + }, + "requiredTools": [ + { + "name": "azure-cli" + } + ], + "when": "type=azure" } ], "agreement": { diff --git a/extensions/asde-deployment/package.nls.json b/extensions/asde-deployment/package.nls.json index 63f548e556..3638a6b4bf 100644 --- a/extensions/asde-deployment/package.nls.json +++ b/extensions/asde-deployment/package.nls.json @@ -4,14 +4,13 @@ "docker-container-name-field": "Container name", "docker-sql-password-field": "SQL Server password", "docker-confirm-sql-password-field": "Confirm password", - "docker-sql-port-field": "Port", + "docker-sql-port-field": "SQL Server Port", "microsoft-privacy-statement": "Microsoft Privacy Statement", "resource-type-sql-edge-display-name": "Azure SQL DB Edge", "resource-type-sql-edge-description": "Deploy Azure SQL DB Edge (Preview)", - "sql-edge-type-display-name": "Type", + "sql-edge-type-display-name": "Deployment target", "sql-edge-local-display-name": "Local", "sql-edge-remote-display-name": "Remote", - "sql-edge-local-notebook": "./notebooks/edge/deploy-sql-edge-local.ipynb", "sql-edge-local-title": "Deploy Azure SQL DB Edge locally", "docker-settings-section-title": "Docker settings", "docker-registry-field": "Registry", @@ -21,10 +20,22 @@ "docker-password-field": "Password", "edge-agreement": "I accept {0} and {1}.", "edge-eula": "Microsoft SQL Server License Agreement", - "sql-edge-remote-notebook": "./notebooks/edge/deploy-sql-edge-remote.ipynb", "sql-edge-remote-title": "Deploy Azure SQL DB Edge on a remote machine", "remote-info-section-title": "Target machine information", "edge-remote-target-field": "Name or IP address", "edge-remote-username-field": "Username", - "edge-remote-password-field": "Password" + "edge-remote-password-field": "Password", + "sql-edge-azure-display-name": "An Azure VM via Azure IoT Hub", + "sql-edge-azure-title": "Deploy Azure SQL DB Edge to an Azure VM via IoT hub", + "azure_subscription_id": "Subscription id", + "azure_resource_group": "Resource group", + "azure_location": "Location", + "vm_admin": "VM admin username", + "vm_password": "VM admin password", + "vm_password_confirm": "Confirm VM admin password", + "iot_device_id": "VM name (device id)", + "dacpac_path": "Dacpac zip file", + "azure-info-section-title": "Azure information", + "sqlserver-info-section-title": "SQL Server information", + "dacpac-zip-files": "Dacpac zip files" } diff --git a/extensions/resource-deployment/src/interfaces.ts b/extensions/resource-deployment/src/interfaces.ts index d157ae73e9..82894fe0f7 100644 --- a/extensions/resource-deployment/src/interfaces.ts +++ b/extensions/resource-deployment/src/interfaces.ts @@ -236,6 +236,9 @@ export interface AzureAccountFieldInfo extends AzureLocationsFieldInfo { displaySubscriptionVariableName?: string; subscriptionVariableName?: string; resourceGroupVariableName?: string; + allowNewResourceGroup?: boolean; + newResourceGroupFlagVariableName?: string; + newResourceGroupNameVariableName?: string; } export interface AzureLocationsFieldInfo extends FieldInfo { @@ -244,6 +247,15 @@ export interface AzureLocationsFieldInfo extends FieldInfo { locations?: string[] } +export interface FilePickerFieldInfo extends FieldInfo { + filter: FilePickerFilter; +} + +export interface FilePickerFilter { + displayName: string; + fileTypes: string[]; +} + export const enum LabelPosition { Top = 'top', Left = 'left' diff --git a/extensions/resource-deployment/src/localizedConstants.ts b/extensions/resource-deployment/src/localizedConstants.ts index c047e6850c..75f5f4b29e 100644 --- a/extensions/resource-deployment/src/localizedConstants.ts +++ b/extensions/resource-deployment/src/localizedConstants.ts @@ -15,3 +15,9 @@ export const browse = localize('filePicker.browse', "Browse"); export const select = localize('filePicker.select', "Select"); export const kubeConfigFilePath = localize('kubeConfigClusterPicker.kubeConfigFilePatht', "Kube config file path"); export const clusterContextNotFound = localize('kubeConfigClusterPicker.clusterContextNotFound', "No cluster context information found"); +export const signIn = localize('azure.signin', "Sign in…"); +export const refresh = localize('azure.refresh', "Refresh"); +export const createNewResourceGroup = localize('azure.resourceGroup.createNewResourceGroup', "Create a new resource group"); +export const NewResourceGroupAriaLabel = localize('azure.resourceGroup.NewResourceGroupAriaLabel', "New resource group name"); + + diff --git a/extensions/resource-deployment/src/ui/modelViewUtils.ts b/extensions/resource-deployment/src/ui/modelViewUtils.ts index 78459c83a8..4acbe747a2 100644 --- a/extensions/resource-deployment/src/ui/modelViewUtils.ts +++ b/extensions/resource-deployment/src/ui/modelViewUtils.ts @@ -8,7 +8,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import * as nls from 'vscode-nls'; import { azureResource } from '../../../azurecore/src/azureResource/azure-resource'; -import { AzureAccountFieldInfo, AzureLocationsFieldInfo, ComponentCSSStyles, DialogInfoBase, FieldInfo, FieldType, KubeClusterContextFieldInfo, LabelPosition, NoteBookEnvironmentVariablePrefix, OptionsInfo, OptionsType, PageInfoBase, RowInfo, SectionInfo, TextCSSStyles } from '../interfaces'; +import { AzureAccountFieldInfo, AzureLocationsFieldInfo, ComponentCSSStyles, DialogInfoBase, FieldInfo, FieldType, KubeClusterContextFieldInfo, LabelPosition, NoteBookEnvironmentVariablePrefix, OptionsInfo, OptionsType, PageInfoBase, RowInfo, SectionInfo, TextCSSStyles, FilePickerFieldInfo } from '../interfaces'; import * as loc from '../localizedConstants'; import { getDefaultKubeConfigPath, getKubeConfigClusterContexts } from '../services/kubeService'; import { assert, getDateTimeString, getErrorMessage } from '../utils'; @@ -98,6 +98,12 @@ interface AzureAccountFieldContext extends FieldContext { fieldInfo: AzureAccountFieldInfo; } +interface AzureAccountComponents { + accountDropdown: azdata.DropDownComponent; + signInButton: azdata.ButtonComponent; + refreshAccountsButton: azdata.ButtonComponent; +} + interface ContextBase { container: azdata.window.Dialog | azdata.window.Wizard; inputComponents: InputComponents; @@ -602,18 +608,32 @@ function processCheckboxField(context: FieldContext): void { * @param context The context to use to create the field */ function processFilePickerField(context: FieldContext): FilePickerInputs { + const inputWidth = parseInt(context.fieldInfo.inputWidth!); + if (inputWidth === NaN) { + // this is a dev time only error + throw new Error('Unable to parse the input width of the file picker field'); + } + const buttonWidth = 100; + const label = createLabel(context.view, { text: context.fieldInfo.label, description: context.fieldInfo.description, required: context.fieldInfo.required, width: context.fieldInfo.labelWidth, cssStyles: context.fieldInfo.labelCSSStyles }); const input = createTextInput(context.view, { defaultValue: context.fieldInfo.defaultValue || '', ariaLabel: context.fieldInfo.label, required: context.fieldInfo.required, placeHolder: context.fieldInfo.placeHolder, - width: context.fieldInfo.inputWidth, + width: `${inputWidth - buttonWidth}px`, enabled: context.fieldInfo.enabled }); context.onNewInputComponentCreated(context.fieldInfo.variableName!, { component: input }); input.enabled = false; - const browseFileButton = context.view!.modelBuilder.button().withProperties({ label: loc.browse }).component(); + const browseFileButton = context.view!.modelBuilder.button().withProperties({ label: loc.browse, width: buttonWidth }).component(); + const fieldInfo = context.fieldInfo as FilePickerFieldInfo; + let filter: { [name: string]: string[] } | undefined = undefined; + if (fieldInfo.filter) { + const filterName = fieldInfo.filter.displayName; + filter = {}; + filter[filterName] = fieldInfo.filter.fileTypes; + } context.onNewDisposableCreated(browseFileButton.onDidClick(async () => { let fileUris = await vscode.window.showOpenDialog({ canSelectFiles: true, @@ -621,9 +641,7 @@ function processFilePickerField(context: FieldContext): FilePickerInputs { canSelectMany: false, defaultUri: vscode.Uri.file(path.dirname(input.value || os_homedir())), openLabel: loc.select, - filters: { - 'Config Files': ['*'], - } + filters: filter }); if (!fileUris || fileUris.length === 0) { return; @@ -631,8 +649,8 @@ function processFilePickerField(context: FieldContext): FilePickerInputs { let fileUri = fileUris[0]; input.value = fileUri.fsPath; })); - context.fieldInfo.labelPosition = LabelPosition.Left; - addLabelInputPairToContainer(context.view, context.components, label, input, context.fieldInfo, [browseFileButton]); + const component = createFlexContainer(context.view, [input, browseFileButton], true, context.fieldInfo.inputWidth); + addLabelInputPairToContainer(context.view, context.components, label, component, context.fieldInfo); return { input: input, browseButton: browseFileButton }; } @@ -684,6 +702,7 @@ async function processKubeConfigClusterPickerField(context: KubeClusterContextFi inputWidth: context.fieldInfo.inputWidth, labelWidth: context.fieldInfo.labelWidth, variableName: kubeConfigFilePathVariableName, + labelPosition: LabelPosition.Left, required: true } }; @@ -731,31 +750,66 @@ async function processAzureAccountField(context: AzureAccountFieldContext): Prom context.fieldInfo.subFields = []; const accountValueToAccountMap = new Map(); const subscriptionValueToSubscriptionMap = new Map(); - const accountDropdown = createAzureAccountDropdown(context); + const accountComponents = createAzureAccountDropdown(context); + const accountDropdown = accountComponents.accountDropdown; const subscriptionDropdown = createAzureSubscriptionDropdown(context, subscriptionValueToSubscriptionMap); const resourceGroupDropdown = createAzureResourceGroupsDropdown(context, accountDropdown, accountValueToAccountMap, subscriptionDropdown, subscriptionValueToSubscriptionMap); + if (context.fieldInfo.allowNewResourceGroup) { + const newRGCheckbox = createCheckbox(context.view, { initialValue: false, label: loc.createNewResourceGroup }); + context.onNewInputComponentCreated(context.fieldInfo.newResourceGroupFlagVariableName!, { component: newRGCheckbox }); + const newRGNameInput = createTextInput(context.view, { ariaLabel: loc.NewResourceGroupAriaLabel }); + context.onNewInputComponentCreated(context.fieldInfo.newResourceGroupNameVariableName!, { component: newRGNameInput }); + context.components.push(newRGCheckbox); + context.components.push(newRGNameInput); + const setRGStatus = (newRG: boolean) => { + resourceGroupDropdown.required = !newRG; + resourceGroupDropdown.enabled = !newRG; + newRGNameInput.required = newRG; + newRGNameInput.enabled = newRG; + if (!newRG) { + newRGNameInput.value = ''; + } + }; + context.onNewDisposableCreated(newRGCheckbox.onChanged(() => { + setRGStatus(newRGCheckbox.checked!); + })); + setRGStatus(false); + } const locationDropdown = context.fieldInfo.locations && await processAzureLocationsField(context); accountDropdown.onValueChanged(async selectedItem => { const selectedAccount = accountValueToAccountMap.get(selectedItem.selected)!; await handleSelectedAccountChanged(context, selectedAccount, subscriptionDropdown, subscriptionValueToSubscriptionMap, resourceGroupDropdown, locationDropdown); }); - try { - const accounts = await azdata.accounts.getAllAccounts(); - // Append a blank value for the "default" option if the field isn't required, context will clear all the dropdowns when selected - const dropdownValues = context.fieldInfo.required ? [] : ['']; - accountDropdown.values = dropdownValues.concat(accounts.map(account => { - const displayName = `${account.displayInfo.displayName} (${account.displayInfo.userId})`; - accountValueToAccountMap.set(displayName, account); - return displayName; - })); - const selectedAccount = accountDropdown.value ? accountValueToAccountMap.get(accountDropdown.value.toString()) : undefined; - await handleSelectedAccountChanged(context, selectedAccount, subscriptionDropdown, subscriptionValueToSubscriptionMap, resourceGroupDropdown, locationDropdown); - } catch (error) { - vscode.window.showErrorMessage(localize('azure.accounts.unexpectedAccountsError', 'Unexpected error fetching accounts: ${0}', getErrorMessage(error))); - } + + const populateAzureAccounts = async () => { + accountValueToAccountMap.clear(); + try { + const accounts = await azdata.accounts.getAllAccounts(); + // Append a blank value for the "default" option if the field isn't required, context will clear all the dropdowns when selected + const dropdownValues = context.fieldInfo.required ? [] : ['']; + accountDropdown.values = dropdownValues.concat(accounts.map(account => { + const displayName = `${account.displayInfo.displayName} (${account.displayInfo.userId})`; + accountValueToAccountMap.set(displayName, account); + return displayName; + })); + const selectedAccount = accountDropdown.value ? accountValueToAccountMap.get(accountDropdown.value.toString()) : undefined; + await handleSelectedAccountChanged(context, selectedAccount, subscriptionDropdown, subscriptionValueToSubscriptionMap, resourceGroupDropdown, locationDropdown); + } catch (error) { + vscode.window.showErrorMessage(localize('azure.accounts.unexpectedAccountsError', 'Unexpected error fetching accounts: ${0}', getErrorMessage(error))); + } + }; + + context.onNewDisposableCreated(accountComponents.refreshAccountsButton.onDidClick(async () => { + await populateAzureAccounts(); + })); + context.onNewDisposableCreated(accountComponents.signInButton.onDidClick(async () => { + await vscode.commands.executeCommand('workbench.actions.modal.linkedAccount'); + await populateAzureAccounts(); + })); + await populateAzureAccounts(); } -function createAzureAccountDropdown(context: AzureAccountFieldContext): azdata.DropDownComponent { +function createAzureAccountDropdown(context: AzureAccountFieldContext): AzureAccountComponents { const label = createLabel(context.view, { text: loc.account, description: context.fieldInfo.description, @@ -771,8 +825,18 @@ function createAzureAccountDropdown(context: AzureAccountFieldContext): azdata.D }); accountDropdown.fireOnTextChange = true; context.onNewInputComponentCreated(context.fieldInfo.variableName!, { component: accountDropdown }); + const signInButton = context.view!.modelBuilder.button().withProperties({ label: loc.signIn, width: '100px' }).component(); + const refreshButton = context.view!.modelBuilder.button().withProperties({ label: loc.refresh, width: '100px' }).component(); addLabelInputPairToContainer(context.view, context.components, label, accountDropdown, context.fieldInfo); - return accountDropdown; + + const buttons = createFlexContainer(context.view!, [signInButton, refreshButton], true, undefined, undefined, undefined, { 'margin-right': '10px' }); + context.components.push(buttons); + return { + accountDropdown: accountDropdown, + signInButton: signInButton, + refreshAccountsButton: refreshButton + }; + } function createAzureSubscriptionDropdown( diff --git a/src/sql/workbench/contrib/accounts/browser/accounts.contribution.ts b/src/sql/workbench/contrib/accounts/browser/accounts.contribution.ts index 62eb6438e7..d0dedc0b61 100644 --- a/src/sql/workbench/contrib/accounts/browser/accounts.contribution.ts +++ b/src/sql/workbench/contrib/accounts/browser/accounts.contribution.ts @@ -6,8 +6,8 @@ import { CommandsRegistry } from 'vs/platform/commands/common/commands'; import { IAccountManagementService } from 'sql/platform/accounts/common/interfaces'; -CommandsRegistry.registerCommand('workbench.actions.modal.linkedAccount', accessor => { +CommandsRegistry.registerCommand('workbench.actions.modal.linkedAccount', async accessor => { const accountManagementService = accessor.get(IAccountManagementService); - accountManagementService.openAccountListDialog(); + await accountManagementService.openAccountListDialog(); }); diff --git a/src/sql/workbench/services/accountManagement/browser/accountManagementService.ts b/src/sql/workbench/services/accountManagement/browser/accountManagementService.ts index 45c7278cda..169b697791 100644 --- a/src/sql/workbench/services/accountManagement/browser/accountManagementService.ts +++ b/src/sql/workbench/services/accountManagement/browser/accountManagementService.ts @@ -297,7 +297,7 @@ export class AccountManagementService implements IAccountManagementService { // UI METHODS ////////////////////////////////////////////////////////// /** * Opens the account list dialog - * @return Promise that finishes when the account list dialog opens + * @return Promise that finishes when the account list dialog closes */ public openAccountListDialog(): Thenable { let self = this; @@ -308,9 +308,8 @@ export class AccountManagementService implements IAccountManagementService { if (!self._accountDialogController) { self._accountDialogController = self._instantiationService.createInstance(AccountDialogController); } - self._accountDialogController.openAccountDialog(); - resolve(); + self._accountDialogController.accountDialog.onCloseEvent(resolve); } catch (e) { reject(e); } diff --git a/src/sql/workbench/services/accountManagement/test/browser/accountManagementService.test.ts b/src/sql/workbench/services/accountManagement/test/browser/accountManagementService.test.ts index e732b527ca..8253127dbe 100644 --- a/src/sql/workbench/services/accountManagement/test/browser/accountManagementService.test.ts +++ b/src/sql/workbench/services/accountManagement/test/browser/accountManagementService.test.ts @@ -16,6 +16,8 @@ import { InstantiationService } from 'vs/platform/instantiation/common/instantia import { TestStorageService } from 'vs/workbench/test/common/workbenchTestServices'; import { EventVerifierSingle } from 'sql/base/test/common/event'; import { TestNotificationService } from 'vs/platform/notification/test/common/testNotificationService'; +import { AccountDialog } from 'sql/workbench/services/accountManagement/browser/accountDialog'; +import { Emitter } from 'vs/base/common/event'; // SUITE CONSTANTS ///////////////////////////////////////////////////////// const hasAccountProvider: azdata.AccountProviderMetadata = { @@ -398,7 +400,14 @@ suite('Account Management Service Tests:', () => { // ... Add mocking for instantiating an account dialog controller let mockDialogController = TypeMoq.Mock.ofType(AccountDialogController); + let mockAccountDialog = {}; mockDialogController.setup(x => x.openAccountDialog()); + mockDialogController.setup(x => x.accountDialog).returns(() => mockAccountDialog); + let mockAccountDialogCloseEvent = new Emitter(); + mockAccountDialog['onCloseEvent'] = mockAccountDialogCloseEvent.event; + setTimeout(() => { + mockAccountDialogCloseEvent.fire(); + }, 1000); state.instantiationService.setup(x => x.createInstance(TypeMoq.It.isValue(AccountDialogController))) .returns(() => mockDialogController.object); @@ -421,13 +430,25 @@ suite('Account Management Service Tests:', () => { // ... Add mocking for instantiating an account dialog controller let mockDialogController = TypeMoq.Mock.ofType(AccountDialogController); + let mockAccountDialog = {}; mockDialogController.setup(x => x.openAccountDialog()); + mockDialogController.setup(x => x.accountDialog).returns(() => mockAccountDialog); + let mockAccountDialogCloseEvent = new Emitter(); + mockAccountDialog['onCloseEvent'] = mockAccountDialogCloseEvent.event; + setTimeout(() => { + mockAccountDialogCloseEvent.fire(); + }, 1000); state.instantiationService.setup(x => x.createInstance(TypeMoq.It.isValue(AccountDialogController))) .returns(() => mockDialogController.object); // If: I open the account dialog for a second time return state.accountManagementService.openAccountListDialog() - .then(() => state.accountManagementService.openAccountListDialog()) + .then(() => { + setTimeout(() => { + mockAccountDialogCloseEvent.fire(); + }, 1000); + state.accountManagementService.openAccountListDialog(); + }) .then(() => { // Then: // ... The instantiation service should have only been called once