diff --git a/docs/en_US/cloud_azure_postgresql.rst b/docs/en_US/cloud_azure_postgresql.rst new file mode 100644 index 000000000..2803388e1 --- /dev/null +++ b/docs/en_US/cloud_azure_postgresql.rst @@ -0,0 +1,120 @@ +.. _cloud_azure_postgresql: + +****************************************** +`Azure PostgreSQL Cloud Deployment`:index: +****************************************** + +To deploy a PostgreSQL server on the Azure cloud, follow the below steps. + +.. image:: images/cloud_azure_provider.png + :alt: Cloud Deployment + :align: center + +Once you launch the tool, select the Azure PostgreSQL option. +Click on the *Next* button to proceed further. + + +.. image:: images/cloud_azure_credentials.png + :alt: Cloud Deployment + :align: center + +In the step-2:Credentials, select authentication method either interactive +browser or Azure CLI. Azure CLI will use the currently logged in identity +through the Azure CLI on the local machine. Interactive Browser will +open a browser window to authenticate a user interactively. + +Use the *Azure tenant* id to speicify Azure tenant ID against which user +is aunthenticated. + +Clicking the *Click here to authenticate yourself to Microsoft Azure* +button, user will be redirected to the Microsoft Azure authentication page in a +new browser tab if the Interactive Browser option is selected. +Azure CLI authentication can be used only in Desktop mode. + +Once authentication is comepleted, click on the next button to proceed. + +.. image:: images/cloud_azure_instance.png + :alt: Cloud Deployment + :align: center + +Use the fields from the Instance Specification tab to specify the Instance +details. + +* Use the *Cluster name* field to add a name for the PostgreSQL + server; the name specified will be displayed in the *Browser* tree control too. + +* Select a subscription from the *Subscription* options which are populated based + on user access levels in Azure portal. + +* Select the resource group from *Resource Group* dropdown under which the + PostgreSQL instance will be created. + +* Select the location to deploy PostgreSQL instance from *Location* + options. + +* Select the availablity zone in specified region to deploy PostgreSQL + instance from *Availability zone* options. + +* Use *Database version* options to speicify PostgreSQL database vetsion. + +* Use the *Instance class* field to allocate the computational, network, and + memory capacity required by planned workload of this DB instance. + +* Use the *Instance type* field to select the instance type. + +* Use the *Storage size* option to specify the storage capacity. + +.. image:: images/cloud_azure_network.png + :alt: Cloud Deployment + :align: center + +* Use the *Public IP* field to specify the List of IP Addresses or range of + IP Addresses (start IP Address - end IP address) from which inbound traffic + should be accepted. Add multiple IP addresses/ranges separated with commas, + for example: "192.168.0.50, 192.168.0.100 - 192.168.0.200" + +* User *Zone redundant high availability* option to specify High Availability + option. Zone redundant high availability deploys a standby replica in a + different zone. + The Burstable instance type does not support high availability. + +.. image:: images/cloud_azure_database.png + :alt: Cloud Deployment + :align: center + +Use the fields from the Database Details tab to specify the PostgreSQL database details. + +* Use the drop-down list in the *pgAdmin server group* field to select the parent + node for the server; the server will be displayed in the *Browser* tree + control within the specified group. + +* Use the *Admin username* field to add the database name for the PostgreSQL + server. + +* Use the *Password* field to provide a password that will be supplied when + authenticating with the server. + +* Use the *Confirm password* field to repeat the password. + +Click on the next button to proceed. + +.. image:: images/cloud_azure_review.png + :alt: Cloud Deployment + :align: center + +At the end, review the instance details that you provided. Click on Finish +button to deploy the instance on Azure PostgreSQL. + +Once you click on the finish, one background process will start which will +deploy the instance in the cloud and monitor the progress of the deployment. + +.. image:: images/cloud_azure_bg_process_watcher.png + :alt: Cloud Deployment + :align: center + +The Server will be added to the tree with the cloud deployment icon. Once the +deployment is done, the server details will be updated. + +.. image:: images/cloud_deployment_tree.png + :alt: Cloud Deployment Provider + :align: center diff --git a/docs/en_US/cloud_deployment.rst b/docs/en_US/cloud_deployment.rst index 5bc2e1085..e75f2c01d 100644 --- a/docs/en_US/cloud_deployment.rst +++ b/docs/en_US/cloud_deployment.rst @@ -15,4 +15,5 @@ To launch the *Cloud Deployment...* tool, right click on the *Server Group* or :maxdepth: 2 cloud_aws_rds - cloud_edb_biganimal \ No newline at end of file + cloud_edb_biganimal + cloud_azure_postgresql \ No newline at end of file diff --git a/docs/en_US/images/cloud_aws_provider.png b/docs/en_US/images/cloud_aws_provider.png index e35fd2a36..910d0bf38 100644 Binary files a/docs/en_US/images/cloud_aws_provider.png and b/docs/en_US/images/cloud_aws_provider.png differ diff --git a/docs/en_US/images/cloud_azure_bg_process_watcher.png b/docs/en_US/images/cloud_azure_bg_process_watcher.png new file mode 100644 index 000000000..972838180 Binary files /dev/null and b/docs/en_US/images/cloud_azure_bg_process_watcher.png differ diff --git a/docs/en_US/images/cloud_azure_credentials.png b/docs/en_US/images/cloud_azure_credentials.png new file mode 100644 index 000000000..44493b807 Binary files /dev/null and b/docs/en_US/images/cloud_azure_credentials.png differ diff --git a/docs/en_US/images/cloud_azure_database.png b/docs/en_US/images/cloud_azure_database.png new file mode 100644 index 000000000..4b76d9e97 Binary files /dev/null and b/docs/en_US/images/cloud_azure_database.png differ diff --git a/docs/en_US/images/cloud_azure_instance.png b/docs/en_US/images/cloud_azure_instance.png new file mode 100644 index 000000000..c88224571 Binary files /dev/null and b/docs/en_US/images/cloud_azure_instance.png differ diff --git a/docs/en_US/images/cloud_azure_network.png b/docs/en_US/images/cloud_azure_network.png new file mode 100644 index 000000000..f065766cc Binary files /dev/null and b/docs/en_US/images/cloud_azure_network.png differ diff --git a/docs/en_US/images/cloud_azure_provider.png b/docs/en_US/images/cloud_azure_provider.png new file mode 100644 index 000000000..a1e2f1474 Binary files /dev/null and b/docs/en_US/images/cloud_azure_provider.png differ diff --git a/docs/en_US/images/cloud_azure_review.png b/docs/en_US/images/cloud_azure_review.png new file mode 100644 index 000000000..56ce09cc9 Binary files /dev/null and b/docs/en_US/images/cloud_azure_review.png differ diff --git a/docs/en_US/images/cloud_biganimal_provider.png b/docs/en_US/images/cloud_biganimal_provider.png index feec225b7..7a0926ce9 100644 Binary files a/docs/en_US/images/cloud_biganimal_provider.png and b/docs/en_US/images/cloud_biganimal_provider.png differ diff --git a/docs/en_US/release_notes_6_11.rst b/docs/en_US/release_notes_6_11.rst index 974f6e0b5..6cc9e125b 100644 --- a/docs/en_US/release_notes_6_11.rst +++ b/docs/en_US/release_notes_6_11.rst @@ -9,6 +9,7 @@ This release contains a number of bug fixes and new features since the release o New features ************ + | `Issue #7178 `_ - Added capability to deploy PostgreSQL servers on Microsoft Azure. | `Issue #7332 `_ - Added support for passing password using Docker Secret to Docker images. | `Issue #7351 `_ - Added the option 'Show template databases?' to display template databases regardless of the setting of 'Show system objects?'. diff --git a/requirements.txt b/requirements.txt index 6ff5f2e9d..552e92c78 100644 --- a/requirements.txt +++ b/requirements.txt @@ -50,3 +50,7 @@ boto3==1.20.* botocore==1.23.* urllib3==1.26.* Werkzeug==2.0.3 +azure-mgmt-rdbms==10.1.0 +azure-mgmt-resource==21.0.0 +azure-mgmt-subscription==3.0.0 +azure-identity==1.9.0 diff --git a/web/pgacloud/providers/azure.py b/web/pgacloud/providers/azure.py new file mode 100644 index 000000000..8539a35c5 --- /dev/null +++ b/web/pgacloud/providers/azure.py @@ -0,0 +1,343 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2022, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +""" Azure PostgreSQL provider """ + +from azure.mgmt.rdbms.postgresql_flexibleservers import \ + PostgreSQLManagementClient +from azure.mgmt.rdbms.postgresql_flexibleservers.models import Sku, SkuTier, \ + CreateMode, Storage, Server, FirewallRule, HighAvailability +from azure.identity import AzureCliCredential, InteractiveBrowserCredential, \ + AuthenticationRecord, TokenCachePersistenceOptions +from azure.mgmt.resource import ResourceManagementClient +from azure.core.exceptions import ResourceNotFoundError +from providers._abstract import AbsProvider +import os +from utils.io import debug, error, output +from utils.misc import get_my_ip, get_random_id + + +class AzureProvider(AbsProvider): + def __init__(self): + self._clients = {} + self._tenant_id = None + self._client_id = None + self._client_secret = None + self._subscription_id = None + self._default_region = None + self._use_interactive_browser_credential = False + self._available_capabilities = None + self._credentials = None + self._authentication_record_json = None + self._cli_credentials = None + + # Get the credentials + if 'AUTHENTICATION_RECORD_JSON' in os.environ: + self._authentication_record_json = os.environ[ + 'AUTHENTICATION_RECORD_JSON'] + + if 'AZURE_SUBSCRIPTION_ID' in os.environ: + self._subscription_id = os.environ['AZURE_SUBSCRIPTION_ID'] + + if 'AZURE_TENANT_ID' in os.environ: + self._tenant_id = os.environ['AZURE_TENANT_ID'] + + if 'AUTH_TYPE' in os.environ: + self._use_interactive_browser_credential = False \ + if os.environ['AUTH_TYPE'] == 'azure_cli_credential' else True + + if 'AZURE_DATABASE_PASSWORD' in os.environ: + self._database_pass = os.environ['AZURE_DATABASE_PASSWORD'] + + def init_args(self, parsers): + """ Create the command line parser for this provider """ + self.parser = parsers. \ + add_parser('azure', + help='Azure Database for PostgreSQL', + epilog='Credentials are read from ' + 'the environment, ' + 'specifically, the ' + 'AZURE_SUBSCRIPTION_ID, ' + 'AZURE_TENANT_ID, ' + 'AZURE_CLIENT_ID and ' + 'AZURE_CLIENT_SECRET ' + 'variables. ' + 'See https://docs.microsoft' + '.com/en-us/azure/developer' + '/python/configure-local' + '-development-environment?tabs=cmd ' + 'for more information.') + + self.parser.add_argument('--region', default=self._default_region, + help='name of the Azure location (default: ' + '{})'.format(self._default_region)) + + self.parser.add_argument('--resource-group', required=True, + help='name of the Azure resource group') + + # Create the command sub-parser + parsers = self.parser.add_subparsers(help='Azure commands', + dest='command') + + # Create the create instance command parser + parser_create_instance = parsers.add_parser('create-instance', + help='create a new ' + 'instance') + + parser_create_instance.add_argument('--name', required=True, + help='name of the instance') + parser_create_instance.add_argument('--db-password', required=False, + help='password for the database') + parser_create_instance.add_argument('--db-username', + default='postgres', + help='user name for the database ' + '(default: postgres)') + parser_create_instance.add_argument('--db-major-version', + default='11', + help='version of PostgreSQL ' + 'to deploy (default: 11)') + parser_create_instance.add_argument('--instance-type', required=True, + help='machine type for the ' + 'instance nodes, e.g. ' + 'GP_Gen5_8') + parser_create_instance.add_argument('--instance_tier_type', + required=True, + help='machine type for the ' + 'instance nodes, e.g. ' + 'GP_Gen5_8') + parser_create_instance.add_argument('--storage-size', type=int, + required=True, + help='storage size in GB') + parser_create_instance.add_argument('--availability-zone', + required=False, + help='Availability zone') + parser_create_instance.add_argument('--high-availability', + required=False, + help='High Availability') + parser_create_instance.add_argument('--public-ips', + default='127.0.0.1', + help='Public IPs ' + '(default: 127.0.0.1)') + + # Create the delete instance command parser + parser_delete_instance = parsers.add_parser('delete-instance', + help='delete an instance') + parser_delete_instance.add_argument('--name', required=True, + help='name of the instance') + + ########################################################################## + # Azure Helper functions + ########################################################################## + def _get_azure_credentials(self): + try: + if self._use_interactive_browser_credential: + if self._authentication_record_json is None: + _credentials = self._azure_interactive_browser_credential() + _auth_record_ = _credentials.authenticate() + self._authentication_record_json = \ + _auth_record_.serialize() + else: + deserialized_auth_record = AuthenticationRecord.\ + deserialize(self._authentication_record_json) + _credentials = \ + self._azure_interactive_browser_credential( + deserialized_auth_record) + else: + if self._cli_credentials is None: + self._cli_credentials = AzureCliCredential() + _credentials = self._cli_credentials + except Exception as e: + return False, str(e) + return True, _credentials + + def _azure_interactive_browser_credential( + self, deserialized_auth_record=None): + if deserialized_auth_record: + _credential = InteractiveBrowserCredential( + tenant_id=self._tenant_id, + timeout=180, + cache_persistence_options=TokenCachePersistenceOptions(), + authentication_record=deserialized_auth_record) + else: + _credential = InteractiveBrowserCredential( + tenant_id=self._tenant_id, + timeout=180, + cache_persistence_options=TokenCachePersistenceOptions()) + return _credential + + def _get_azure_client(self, type): + """ Create/cache/return an Azure client object """ + # Acquire a credential object using CLI-based authentication. + if self._credentials is None: + status, self._credentials = \ + self._get_azure_credentials() + + if type in self._clients: + return self._clients[type] + + if type == 'postgresql': + client = PostgreSQLManagementClient(self._credentials, + self._subscription_id) + elif type == 'resource': + client = ResourceManagementClient(self._credentials, + self._subscription_id) + + self._clients[type] = client + + return self._clients[type] + + def _create_resource_group(self, args): + """ Create the Resource Group if it doesn't exist """ + resource_client = self._get_azure_client('resource') + + group_list = resource_client.resource_groups.list() + for group in list(group_list): + if group.name == args.resource_group: + debug('Resource group already exist with name: {}...'.format( + args.resource_group)) + return group.__dict__ + debug( + 'Creating resource group with name: {}...'.format( + args.resource_group)) + result = resource_client.resource_groups.create_or_update( + args.resource_group, + {"location": args.region}) + return result.__dict__ + + def _create_azure_instance(self, args): + """ Create an Azure instance """ + # Obtain the management client object + postgresql_client = self._get_azure_client('postgresql') + # Check if the server already exists + svr = None + try: + svr = postgresql_client.servers.get(args.resource_group, args.name) + except ResourceNotFoundError: + pass + except Exception as e: + error(args, e) + + if svr is not None: + error(args, 'Azure Database for PostgreSQL instance {} already ' + 'exists.'.format(args.name)) + + db_password = self._database_pass if self._database_pass is not None \ + else args.db_password + + # Provision the server and wait for the result + debug('Creating Azure instance: {}...'.format(args.name)) + + try: + poller = postgresql_client.servers.begin_create( + resource_group_name=args.resource_group, + server_name=args.name, + parameters=Server( + + sku=Sku(name=args.instance_type, + tier=SkuTier(args.instance_tier_type) + ), + high_availability=HighAvailability( + mode=args.high_availability), + administrator_login=args.db_username, + administrator_login_password=db_password, + version=args.db_major_version, + storage=Storage( + storage_size_gb=args.storage_size + ), + location=args.region, + create_mode=CreateMode("Default") + ) + ) + except Exception as e: + error(e) + + server = poller.result() + + return server.__dict__ + + def _create_firewall_rule(self, args): + """ Create a firewall rule on an instance """ + firewall_rules = [] + postgresql_client = self._get_azure_client('postgresql') + ip = args.public_ips if args.public_ips else get_my_ip() + ip_list = ip.split(',') + for ip in ip_list: + ip = ip.strip() + if '-' in ip: + start_ip = ip.split('-')[0] + end_ip = ip.split('-')[1] + else: + start_ip = ip + end_ip = ip + name = 'pgacloud_{}_{}_{}'.format(args.name, + ip.replace('.', '-'), + get_random_id()) + + # Provision the rule and wait for completion + debug('Adding ingress rule for: {0} - {1} ...'.format(start_ip, + end_ip)) + poller = postgresql_client.firewall_rules.begin_create_or_update( + resource_group_name=args.resource_group, + server_name=args.name, + firewall_rule_name=name, + parameters=FirewallRule(start_ip_address=start_ip, + end_ip_address=end_ip) + ) + + firewall_rule = poller.result() + + firewall_rules.append(firewall_rule.__dict__) + return firewall_rules + + def _delete_azure_instance(self, args, name): + """ Delete an Azure instance """ + # Obtain the management client object + postgresql_client = self._get_azure_client('postgresql') + + # Delete the server and wait for the result + debug('Deleting Azure instance: {}...'.format(args.name)) + try: + poller = postgresql_client.servers.begin_delete( + args.resource_group, + args.name + ) + except Exception as e: + error(args, e) + + poller.result() + + ########################################################################## + # User commands + ########################################################################## + def cmd_create_instance(self, args): + """ Deploy an Azure instance and firewall rule """ + rg = self._create_resource_group(args) + instance = self._create_azure_instance(args) + self._create_firewall_rule(args) + + data = {'instance': { + 'Id': instance['id'], + 'ResourceGroupId': rg['name'], + 'Location': instance['location'], + 'Hostname': instance['fully_qualified_domain_name'], + 'Port': 5432, + 'Database': "postgres", + 'Username': instance['administrator_login'] + }} + + output(data) + + def cmd_delete_instance(self, args): + """ Delete an Azure instance """ + self._delete_azure_instance(args, args.name) + + +def load(): + """ Loads the current provider """ + return AzureProvider() diff --git a/web/pgadmin/misc/cloud/__init__.py b/web/pgadmin/misc/cloud/__init__.py index 603e98dba..eff20bad9 100644 --- a/web/pgadmin/misc/cloud/__init__.py +++ b/web/pgadmin/misc/cloud/__init__.py @@ -26,6 +26,7 @@ from pgadmin.misc.cloud.utils import get_my_ip from pgadmin.misc.cloud.biganimal import deploy_on_biganimal,\ clear_biganimal_session from pgadmin.misc.cloud.rds import deploy_on_rds, clear_aws_session +from pgadmin.misc.cloud.azure import deploy_on_azure, clear_azure_session # set template path for sql scripts MODULE_NAME = 'cloud' @@ -75,7 +76,8 @@ class CloudModule(PgAdminModule): return ['cloud.deploy_on_cloud', 'cloud.update_cloud_server', 'cloud.update_cloud_process', - 'cloud.get_host_ip'] + 'cloud.get_host_ip', + 'cloud.clear_cloud_session'] # Create blueprint for CloudModule class @@ -102,6 +104,15 @@ def script(): return res +@blueprint.route('/clear_cloud_session/', + methods=['POST'], endpoint='clear_cloud_session') +@login_required +def clear_session(): + """Get host IP Address""" + clear_cloud_session() + return make_json_response(success=1) + + @blueprint.route('/get_host_ip/', methods=['GET'], endpoint='get_host_ip') @login_required @@ -123,6 +134,8 @@ def deploy_on_cloud(): status, resp = deploy_on_rds(data) elif data['cloud'] == 'biganimal': status, resp = deploy_on_biganimal(data) + elif data['cloud'] == 'azure': + status, resp = deploy_on_azure(data) else: status = False resp = gettext('No cloud implementation.') @@ -188,7 +201,7 @@ def update_server(data): _server['status'] = False else: _server['status'] = True - clear_cloud_session() + clear_cloud_session() return True, _server @@ -197,6 +210,7 @@ def clear_cloud_session(): """Clear cloud sessions.""" clear_aws_session() clear_biganimal_session() + clear_azure_session() @blueprint.route( diff --git a/web/pgadmin/misc/cloud/azure/__init__.py b/web/pgadmin/misc/cloud/azure/__init__.py new file mode 100644 index 000000000..525b6a100 --- /dev/null +++ b/web/pgadmin/misc/cloud/azure/__init__.py @@ -0,0 +1,693 @@ +# ########################################################################## +# # +# # pgAdmin 4 - PostgreSQL Tools +# # +# # Copyright (C) 2013 - 2022, The pgAdmin Development Team +# # This software is released under the PostgreSQL Licence +# # +# ########################################################################## + +# Azure implementation +from pgadmin.misc.cloud.utils import _create_server, CloudProcessDesc +from pgadmin.misc.bgprocess.processes import BatchProcess +from pgadmin import make_json_response +from pgadmin.utils import PgAdminModule +from flask_security import login_required +import simplejson as json +from flask import session, current_app, request +from config import root + +from azure.mgmt.rdbms.postgresql_flexibleservers import \ + PostgreSQLManagementClient +from azure.identity import AzureCliCredential, InteractiveBrowserCredential, \ + TokenCachePersistenceOptions, AuthenticationRecord +from azure.mgmt.resource import ResourceManagementClient +from azure.mgmt.subscription import SubscriptionClient +from azure.mgmt.rdbms.postgresql_flexibleservers.models import \ + NameAvailabilityRequest + +MODULE_NAME = 'azure' + + +class AzurePostgresqlModule(PgAdminModule): + """Cloud module to deploy on Azure Postgresql""" + + def get_own_stylesheets(self): + """ + Returns: + list: the stylesheets used by this module. + """ + stylesheets = [] + return stylesheets + + def get_exposed_url_endpoints(self): + return ['azure.verify_credentials', + 'azure.check_cluster_name_availability', + 'azure.subscriptions', + 'azure.resource_groups', + 'azure.regions', + 'azure.zone_redundant_ha_supported', + 'azure.db_versions', + 'azure.instance_types', + 'azure.availability_zones', + 'azure.storage_types'] + + +blueprint = AzurePostgresqlModule(MODULE_NAME, __name__, + static_url_path='/misc/cloud/azure') + + +@blueprint.route('/verify_credentials/', + methods=['POST'], endpoint='verify_credentials') +@login_required +def verify_credentials(): + """Verify Credentials.""" + data = json.loads(request.data, encoding='utf-8') + session_token = data['secret']['session_token'] if \ + 'session_token' in data['secret'] else None + tenant_id = data['secret']['azure_tenant_id'] if \ + 'azure_tenant_id' in data['secret'] else None + interactive_browser_credential = False if \ + data['secret']['auth_type'] == 'azure_cli_credential' else True + + if 'azure' not in session: + session['azure'] = {} + + error = '' + status = True + if 'azure_obj' not in session['azure'] or \ + session['azure']['auth_type'] != data['secret']['auth_type'] or \ + session['azure']['azure_tenant_id'] != tenant_id: + if 'azure_obj' in session['azure']: + del session['azure']['azure_obj'] + azure = Azure( + interactive_browser_credential=interactive_browser_credential, + tenant_id=tenant_id, + session_token=session_token) + status, error = azure.validate_azure_credentials() + if status: + session['azure']['azure_obj'] = azure + session['azure']['auth_type'] = data['secret']['auth_type'] + session['azure']['azure_tenant_id'] = tenant_id + if not status and 'double check your tenant name' in error: + error = 'Authentication failed.Please double check tenant id.' + return make_json_response(success=status, errormsg=error) + + +@blueprint.route('/check_cluster_name_availability/', + methods=['GET'], endpoint='check_cluster_name_availability') +@login_required +def check_cluster_name_availability(): + """Check Server Name availability.""" + data = request.args + azure = session['azure']['azure_obj'] + server_name_available, error = \ + azure.check_cluster_name_availability(data['name']) + if server_name_available: + return make_json_response(success=server_name_available, + errormsg=error) + else: + return make_json_response( + status=410, + success=0, + errormsg=error) + + +@blueprint.route('/subscriptions/', + methods=['GET'], endpoint='subscriptions') +@login_required +def get_azure_subscriptions(): + """ + List subscriptions. + :return: + """ + azure = session['azure']['azure_obj'] + subscriptions_list = azure.list_subscriptions() + return make_json_response(data=subscriptions_list) + + +@blueprint.route('/resource_groups/', + methods=['GET'], endpoint='resource_groups') +@login_required +def get_azure_resource_groups(subscription_id): + """ + Fetch resource groups based on subscription. + """ + if not subscription_id: + return make_json_response(data=[]) + azure = session['azure']['azure_obj'] + resource_groups_list = azure.list_resource_groups(subscription_id) + return make_json_response(data=resource_groups_list) + + +@blueprint.route('/regions/', + methods=['GET'], endpoint='regions') +@login_required +def get_azure_regions(subscription_id): + """List Regions for Azure.""" + if not subscription_id: + return make_json_response(data=[]) + azure = session['azure']['azure_obj'] + regions_list = azure.list_regions(subscription_id) + session['azure']['azure_obj'] = azure + return make_json_response(data=regions_list) + + +@blueprint.route('/zone_redundant_ha_supported/', + methods=['GET'], endpoint='zone_redundant_ha_supported') +@login_required +def is_ha_supported(region_name): + """Check high availability support in given region.""" + azure = session['azure']['azure_obj'] + is_zone_redundant_ha_supported = \ + azure.is_zone_redundant_ha_supported(region_name) + return make_json_response(data={'is_zone_redundant_ha_supported': + is_zone_redundant_ha_supported}) + + +@blueprint.route('/availability_zones/', + methods=['GET'], endpoint='availability_zones') +@login_required +def get_azure_availability_zones(region_name): + """List availability zones in given region.""" + if not region_name: + return make_json_response(data=[]) + azure = session['azure']['azure_obj'] + availability_zones = azure.list_azure_availability_zones(region_name) + session['azure']['azure_obj'] = azure + return make_json_response(data=availability_zones) + + +@blueprint.route('/db_versions/', + methods=['GET'], endpoint='db_versions') +@login_required +def get_azure_postgresql_server_versions(availability_zone): + """Get azure postgres database versions.""" + if not availability_zone: + return make_json_response(data=[]) + azure = session['azure']['azure_obj'] + azure_postgresql_server_versions = \ + azure.list_azure_postgresql_server_versions(availability_zone) + session['azure']['azure_obj'] = azure + return make_json_response(data=azure_postgresql_server_versions) + + +@blueprint.route('/instance_types//', + methods=['GET'], endpoint='instance_types') +@login_required +def get_azure_instance_types(availability_zone, db_version): + """Get instance types for Azure.""" + if not db_version: + return make_json_response(data=[]) + azure = session['azure']['azure_obj'] + instance_types = azure.list_compute_types(availability_zone, db_version) + return make_json_response(data=instance_types) + + +@blueprint.route('/storage_types//', + methods=['GET'], endpoint='storage_types') +@login_required +def list_azure_storage_types(availability_zone, db_version): + """Get the storage types supported.""" + if not db_version: + return make_json_response(data=[]) + azure = session['azure']['azure_obj'] + storage_types = azure.list_storage_types(availability_zone, db_version) + return make_json_response(data=storage_types) + + +@blueprint.route('/clear_session', + methods=['GET'], endpoint='clear_session') +@login_required +def clear_session(): + clear_azure_session() + return make_json_response(success=1) + + +class Azure: + def __init__(self, interactive_browser_credential, tenant_id=None, + session_token=None, region='eastus'): + self._clients = {} + self._tenant_id = tenant_id + self._session_token = session_token + self._use_interactive_browser_credential = \ + interactive_browser_credential + self.authentication_record_json = None + self._cli_credentials = None + self._credentials = None + self._region = region + self.subscription_id = None + self._availability_zone = None + self._available_capabilities_list = [] + + ########################################################################## + # Azure Helper functions + ########################################################################## + + def validate_azure_credentials(self): + """ + Validates azure credentials + :return: True if valid credentials else false + """ + status, identity = self._get_azure_credentials() + error = '' + if not status: + error = identity + return status, error + + def _get_azure_credentials(self): + """ + Gets azure credentials depending on + self._use_interactive_browser_credential + :return: + """ + try: + if self._use_interactive_browser_credential: + if self.authentication_record_json is None: + _credentials = self._azure_interactive_browser_credential() + _auth_record_ = _credentials.authenticate() + self.authentication_record_json = _auth_record_.serialize() + else: + deserialized_auth_record = AuthenticationRecord. \ + deserialize(self.authentication_record_json) + _credentials = \ + self._azure_interactive_browser_credential( + deserialized_auth_record) + else: + if self._cli_credentials is None: + self._cli_credentials = AzureCliCredential() + self.list_subscriptions() + _credentials = self._cli_credentials + except Exception as e: + return False, str(e) + return True, _credentials + + def _azure_interactive_browser_credential( + self, deserialized_auth_record=None): + if deserialized_auth_record: + _credential = InteractiveBrowserCredential( + tenant_id=self._tenant_id, + timeout=180, + cache_persistence_options=TokenCachePersistenceOptions(), + authentication_record=deserialized_auth_record) + else: + _credential = InteractiveBrowserCredential( + tenant_id=self._tenant_id, + timeout=180, + cache_persistence_options=TokenCachePersistenceOptions()) + return _credential + + def _get_azure_client(self, type): + """ Create/cache/return an Azure client object """ + if type in self._clients: + return self._clients[type] + + status, _credentials = self._get_azure_credentials() + + if type == 'postgresql': + client = PostgreSQLManagementClient(_credentials, + self.subscription_id) + elif type == 'resource': + client = ResourceManagementClient(_credentials, + self.subscription_id) + elif type == 'subscription': + client = SubscriptionClient(_credentials) + + self._clients[type] = client + return self._clients[type] + + def check_cluster_name_availability(self, cluster_name): + """ + Checks whether given server name is available or not + :param cluster_name + """ + postgresql_client = self._get_azure_client('postgresql') + res = postgresql_client.check_name_availability.execute( + NameAvailabilityRequest( + name=cluster_name, + type='Microsoft.DBforPostgreSQL/flexibleServers')) + res = res.__dict__ + return res['name_available'], res['message'] + + def list_subscriptions(self): + """ + List subscriptions + :return: + """ + subscription_client = self._get_azure_client('subscription') + sub_list = subscription_client.subscriptions.list() + subscriptions_list = [] + for group in list(sub_list): + subscriptions_list.append( + {'subscription_id': group.subscription_id, + 'subscription_name': group.display_name}) + return subscriptions_list + + def list_resource_groups(self, subscription_id): + """ + List the resource groups + :param subscription_id: + :return: + """ + self.subscription_id = subscription_id + resource_client = self._get_azure_client('resource') + group_list = resource_client.resource_groups.list() + resource_groups_list = [] + for group in list(group_list): + resource_groups_list.append( + {'label': group.name, + 'value': group.name, + 'region': group.location}) + return resource_groups_list + + def list_regions(self, subscription_id): + """ + List regions depending on subscription id + :param subscription_id: + :return: + """ + self.subscription_id = subscription_id + subscription_client = self._get_azure_client('subscription') + locations = subscription_client.subscriptions.list_locations( + subscription_id=self.subscription_id) + locations_list = [] + for location in locations: + locations_list.append( + {'label': location.display_name, 'value': location.name}) + return locations_list + + def is_zone_redundant_ha_supported(self, region): + if self._region == region and \ + len(self._available_capabilities_list) > 1: + return self._available_capabilities_list[0][ + 'zone_redundant_ha_supported'] + else: + self._available_capabilities_list = \ + self._get_available_capabilities_list(region) + return self._available_capabilities_list[0][ + 'zone_redundant_ha_supported'] + + def list_azure_availability_zones(self, region): + """ + List availability zones in the region + :param region: + :return: + """ + self._region = region + self._available_capabilities_list = \ + self._get_available_capabilities_list(region) + availability_zones_list = [] + for capability in self._available_capabilities_list: + zone = str(capability['zone']) + if capability['zone'] == 'none': + availability_zones_list.append({'label': 'No Preference', + 'value': zone}) + else: + availability_zones_list.append({'label': zone, + 'value': zone}) + return availability_zones_list + + def list_azure_postgresql_server_versions(self, availability_zone): + """ + :param availability_zone: + :return: List of postgresql version available in specified availability + zone. + """ + self._availability_zone = availability_zone + server_versions_list = [] + for capability in self._available_capabilities_list: + if str(capability['zone']) == availability_zone: + for supported_server_version in \ + capability['supported_server_versions']: + server_version = supported_server_version['server_version'] + server_versions_list.append({'label': str(server_version), + 'value': server_version}) + return server_versions_list + + def list_compute_types(self, availability_zone, server_version): + """ + :param availability_zone: + :param server_version: + :return: list of compute classes based on specified availability + zone & server version. + """ + compute_types_list = [] + for capability in self._available_capabilities_list: + if str(capability['zone']) == availability_zone: + for supported_server_version in \ + capability['supported_server_versions']: + if supported_server_version['server_version'] == \ + server_version: + compute_types = \ + supported_server_version['compute_types'] + for value in compute_types: + compute_types_list.append( + {'label': value['display_name'], + 'value': value['name'], + 'type': value['type']}) + return compute_types_list + + def list_storage_types(self, availability_zone, server_version): + """ + + :param availability_zone: + :param server_version: + :return: list of storages classes based on specified availability + """ + storage_types_list = [] + + for capability in self._available_capabilities_list: + if str(capability['zone']) == availability_zone: + for supported_server_version in \ + capability['supported_server_versions']: + if supported_server_version['server_version'] == \ + server_version: + storage_types = \ + supported_server_version['storage_types'] + for value in storage_types: + storage_types_list.append({ + 'label': str(value['storage_size_gb']) + + ' GiB', + 'value': value['storage_size_gb'], + 'type': value['type']}) + return storage_types_list + + def _get_available_capabilities_list(self, region): + """ + list capabilities & serialize them to normal list-dict format + :param region: + :return: + """ + available_capabilities = \ + self._get_available_capabilities_object(region) + return self.\ + _serialize_available_capabilities_list(available_capabilities) + + def _get_available_capabilities_object(self, region): + """ + :param region: + :return: azure capabilities object + """ + postgresql_client = self._get_azure_client('postgresql') + return postgresql_client.location_based_capabilities.execute( + location_name=region) + + @staticmethod + def _serialize_available_capabilities_list(available_capabilities): + """ + :param available_capabilities: + :return: serialized available capabilities list + """ + available_capabilities_list = [] + for capability in available_capabilities: + supported_server_version_dict = {} + storage_types = [] + for supported_flexible_server_edition in \ + capability.supported_flexible_server_editions: + compute_type = supported_flexible_server_edition.name + + storage_types = Azure. \ + _get_storage_types(compute_type, + supported_flexible_server_edition, + storage_types) + + supported_server_version_dict = Azure. \ + _get_compute_types(compute_type, + supported_flexible_server_edition, + supported_server_version_dict, + storage_types) + + supported_server_version_list = [] + for key, value in supported_server_version_dict.items(): + supported_server_version_list.append( + {'server_version': key, + 'compute_types': value['compute_types'], + 'storage_types': value['storage_types']}) + + available_capabilities_list.append( + {'zone': capability.zone, + 'zone_redundant_ha_supported': + capability.zone_redundant_ha_supported, + 'supported_server_versions': + supported_server_version_list}) + + return available_capabilities_list + + @staticmethod + def _get_storage_types(compute_type, supported_flexible_server_edition, + storage_types): + for supported_storage_edition in \ + supported_flexible_server_edition.supported_storage_editions: + for supported_storage_mb in \ + supported_storage_edition.supported_storage_mb: + supported_storage_mb_dict = supported_storage_mb.__dict__ + storage_types.append({'type': compute_type, + 'storage_size_gb': + int(supported_storage_mb_dict[ + 'storage_size_mb'] / 1024)}) + return storage_types + + @staticmethod + def _get_compute_types(compute_type, supported_flexible_server_edition, + supported_server_version_dict, storage_types): + for supported_server_version in \ + supported_flexible_server_edition.supported_server_versions: + if not supported_server_version.name.isnumeric(): + continue + + if supported_server_version.name not in \ + supported_server_version_dict: + supported_server_version_dict[ + supported_server_version.name] = {} + + compute_types_list = [] + for supported_vcore in supported_server_version.supported_vcores: + vcore_dict = supported_vcore.__dict__ + compute_types_list.append( + {'type': compute_type, + 'name': vcore_dict['name'], + 'supportedIOPS': vcore_dict['additional_properties'][ + 'supportedIOPS'], + 'display_name': vcore_dict['name'] + ' (' + + str(vcore_dict['v_cores']) + ' vCores, ' + + str(int(vcore_dict['supported_memory_per_vcore_mb'] / + 1024 * vcore_dict['v_cores'])) + 'GiB memory, ' + + str(vcore_dict['additional_properties'] + ['supportedIOPS']) + + ' max iops)' + }) + + if 'compute_types' not in supported_server_version_dict[ + supported_server_version.name]: + supported_server_version_dict[supported_server_version.name][ + 'compute_types'] = compute_types_list + else: + supported_server_version_dict[supported_server_version.name][ + 'compute_types'] = \ + supported_server_version_dict[ + supported_server_version.name]['compute_types'] + ( + compute_types_list) + + supported_server_version_dict[supported_server_version.name][ + 'storage_types'] = storage_types + + return supported_server_version_dict + + +def deploy_on_azure(data): + """Deploy the Postgres instance on Azure.""" + _cmd = 'python' + _cmd_script = '{0}/pgacloud/pgacloud.py'.format(root) + _label = data['instance_details']['name'] + + if 'high_availability' in data['instance_details']: + if data['instance_details']['high_availability']: + data['instance_details']['high_availability'] = "ZoneRedundant" + else: + data['instance_details']['high_availability'] = "Disabled" + + args = [_cmd_script, + + 'azure', + + '--region', + str(data['instance_details']['region']), + + '--resource-group', + data['instance_details']['resource_group'], + + 'create-instance', + '--name', + data['instance_details']['name'], + + '--db-username', + data['db_details']['db_username'], + + '--db-major-version', + str(data['instance_details']['db_version']), + + '--instance_tier_type', + data['instance_details']['db_instance_class'], + + '--instance-type', + data['instance_details']['instance_type'], + + '--storage-size', + str(data['instance_details']['storage_size']), + + '--public-ips', + str(data['instance_details']['public_ips']), + + '--availability-zone', + str(data['instance_details']['availability_zone']), + + '--high-availability', + data['instance_details']['high_availability'] + ] + + _cmd_msg = '{0} {1} {2}'.format(_cmd, _cmd_script, ' '.join(args)) + try: + sid = _create_server({ + 'gid': data['db_details']['gid'], + 'name': data['instance_details']['name'], + 'db': 'postgres', + 'username': data['db_details']['db_username'], + 'port': 5432, + 'cloud_status': -1 + }) + + p = BatchProcess( + desc=CloudProcessDesc(sid, _cmd_msg, data['cloud'], + data['instance_details']['name']), + cmd=_cmd, + args=args + ) + + env = dict() + + azure = session['azure']['azure_obj'] + env['AZURE_SUBSCRIPTION_ID'] = azure.subscription_id + env['AUTH_TYPE'] = data['secret']['auth_type'] + if azure.authentication_record_json is not None: + env['AUTHENTICATION_RECORD_JSON'] = \ + azure.authentication_record_json + env['AZURE_TENANT_ID'] = data['secret']['azure_tenant_id'] + + if 'db_password' in data['db_details']: + env['AZURE_DATABASE_PASSWORD'] = data[ + 'db_details']['db_password'] + + p.set_env_variables(None, env=env) + p.update_server_id(p.id, sid) + p.start() + del session['azure']['azure_obj'] + return True, {'label': _label, 'sid': sid} + except Exception as e: + current_app.logger.exception(e) + return False, str(e) + + +def clear_azure_session(): + """Clear session data.""" + if 'azure' in session: + session.pop('azure') diff --git a/web/pgadmin/misc/cloud/static/js/CloudWizard.jsx b/web/pgadmin/misc/cloud/static/js/CloudWizard.jsx index 4aea8e6c8..0a03be100 100644 --- a/web/pgadmin/misc/cloud/static/js/CloudWizard.jsx +++ b/web/pgadmin/misc/cloud/static/js/CloudWizard.jsx @@ -21,12 +21,12 @@ import PropTypes from 'prop-types'; import pgAdmin from 'sources/pgadmin'; import {ToggleButtons, FinalSummary} from './cloud_components'; import { PrimaryButton } from '../../../../static/js/components/Buttons'; -import {AwsCredentials, AwsInstanceDetails, AwsDatabaseDetails, validateCloudStep1, - validateCloudStep2, validateCloudStep3} from './aws'; -import {BigAnimalInstance, BigAnimalDatabase, validateBigAnimal, - validateBigAnimalStep2, validateBigAnimalStep3} from './biganimal'; +import {AwsCredentials, AwsInstanceDetails, AwsDatabaseDetails, validateCloudStep1, validateCloudStep2, validateCloudStep3} from './aws'; +import {BigAnimalInstance, BigAnimalDatabase, validateBigAnimal,validateBigAnimalStep2, validateBigAnimalStep3} from './biganimal'; import { isEmptyString } from 'sources/validators'; -import { AWSIcon, BigAnimalIcon } from '../../../../static/js/components/ExternalIcon'; +import { AWSIcon, BigAnimalIcon, AzureIcon } from '../../../../static/js/components/ExternalIcon'; +import {AzureCredentials, AzureInstanceDetails, AzureDatabaseDetails, checkClusternameAvailbility, validateAzureStep2, validateAzureStep3} from './azure'; +import EventBus from '../../../../static/js/helpers/EventBus'; const useStyles = makeStyles(() => ({ @@ -53,12 +53,20 @@ const useStyles = makeStyles(() => boxText: { paddingBottom: '5px' }, + authButton: { + marginLeft: '12em' + } }), ); +export const CloudWizardEventsContext = React.createContext(); + + export default function CloudWizard({ nodeInfo, nodeData }) { const classes = useStyles(); + const eventBus = React.useRef(new EventBus()); + var steps = [gettext('Cloud Provider'), gettext('Credentials'), gettext('Instance Specification'), gettext('Database Details'), gettext('Review')]; const [currentStep, setCurrentStep] = React.useState(''); @@ -74,13 +82,27 @@ export default function CloudWizard({ nodeInfo, nodeData }) { const [bigAnimalInstanceData, setBigAnimalInstanceData] = React.useState({}); const [bigAnimalDatabaseData, setBigAnimalDatabaseData] = React.useState({}); - + const [azureCredData, setAzureCredData] = React.useState({}); + const [azureInstanceData, setAzureInstanceData] = React.useState({}); + const [azureDatabaseData, setAzureDatabaseData] = React.useState({}); const axiosApi = getApiInstance(); const [verificationURI, setVerificationURI] = React.useState(''); const [verificationCode, setVerificationCode] = React.useState(''); + React.useEffect(()=>{ + eventBus.current.registerListener('SET_ERROR_MESSAGE_FOR_CLOUD_WIZARD', (msg) => { + setErrMsg(msg); + }); + }, []); + + React.useEffect(()=>{ + eventBus.current.registerListener('SET_CRED_VERIFICATION_INITIATED', (initiated) => { + setVerificationIntiated(initiated); + }); + }, []); + React.useEffect(() => { let _url = url_for('cloud.get_host_ip') ; axiosApi.get(_url) @@ -110,7 +132,16 @@ export default function CloudWizard({ nodeInfo, nodeData }) { instance_details:cloudInstanceDetails, db_details: cloudDBDetails }; - } else { + } else if(cloudProvider == 'azure'){ + post_data = { + gid: nodeInfo.server_group._id, + secret: azureCredData, + cloud: cloudProvider, + instance_details:azureInstanceData, + db_details: azureDatabaseData + }; + + }else { post_data = { gid: nodeInfo.server_group._id, cloud: cloudProvider, @@ -170,6 +201,24 @@ export default function CloudWizard({ nodeInfo, nodeData }) { break; } break; + case 'azure': + switch (currentStep) { + case 0: + setCloudSelection('azure'); + break; + case 1: + isError = !verificationIntiated; + break; + case 2: + isError = validateAzureStep2(azureInstanceData); + break; + case 3: + isError = validateAzureStep3(azureDatabaseData, nodeInfo); + break; + default: + break; + } + break; } return isError; }; @@ -211,8 +260,23 @@ export default function CloudWizard({ nodeInfo, nodeData }) { setErrMsg([MESSAGE_TYPE.ERROR, gettext(error)]); reject(); }); + } else if(activeStep == 2 && cloudProvider == 'azure'){ + setErrMsg([MESSAGE_TYPE.INFO, 'Checking cluster name availability...']); + checkClusternameAvailbility(azureInstanceData.name) + .then((res)=>{ + if (res.data && res.data.success == 0 ) { + setErrMsg([MESSAGE_TYPE.ERROR, gettext('Specified cluster name is already used.')]); + }else{ + setErrMsg(['', '']); + } + resolve(); + }).catch((error)=>{ + setErrMsg([MESSAGE_TYPE.ERROR, gettext(error)]); + reject(); + }); } else { + setErrMsg(['', '']); resolve(); } }); @@ -262,96 +326,120 @@ export default function CloudWizard({ nodeInfo, nodeData }) { }); return ( - <> - - - - {gettext('Select any option to deploy on cloud.')} - - - }, {label: 'EDB BigAnimal', value: 'biganimal', icon: }]} - > - - - {gettext('More cloud providers are coming soon...')} - - - - - - {cloudProvider == 'biganimal' && - {gettext('The verification code to authenticate the pgAdmin to EDB BigAnimal is: ')} {verificationCode} -
{gettext('By clicking the below button, you will be redirected to the EDB BigAnimal authentication page in a new tab.')} -
-
} - {cloudProvider == 'biganimal' && - {gettext('Click here to authenticate yourself to EDB BigAnimal')} - } - {cloudProvider == 'biganimal' && - - } -
- {cloudProvider == 'rds' && } - -
- - {cloudProvider == 'rds' && callRDSAPI == 2 && } - {cloudProvider == 'biganimal' && callRDSAPI == 2 && } - - - {cloudProvider == 'rds' && - } - {cloudProvider == 'biganimal' && callRDSAPI == 3 && - } - - - {gettext('Please review the details before creating the cloud instance.')} - - {cloudProvider == 'rds' && callRDSAPI == 4 && + <> + + + + {gettext('Select a cloud provider.')} + + + }, {label: 'EDB BigAnimal', value: 'biganimal', icon: }, {'label': 'Azure PostgreSQL', value: 'azure', icon: }]} + > + + + + + + {cloudProvider == 'biganimal' && + {gettext('The verification code to authenticate the pgAdmin to EDB BigAnimal is: ')} {verificationCode} +
{gettext('By clicking the below button, you will be redirected to the EDB BigAnimal authentication page in a new tab.')} +
+
} + {cloudProvider == 'biganimal' && + {gettext('Click here to authenticate yourself to EDB BigAnimal')} + } + {cloudProvider == 'biganimal' && + + } +
+ {cloudProvider == 'rds' && } + + {cloudProvider == 'azure' && } + + +
+ + {cloudProvider == 'rds' && callRDSAPI == 2 && } + {cloudProvider == 'biganimal' && callRDSAPI == 2 && } + {cloudProvider == 'azure' && callRDSAPI == 2 && } + + + + {cloudProvider == 'rds' && } - {cloudProvider == 'biganimal' && callRDSAPI == 4 && } -
-
-
- + {cloudProvider == 'azure' && + } + + + {gettext('Please review the details before creating the cloud instance.')} + + {cloudProvider == 'rds' && callRDSAPI == 4 && + } + {cloudProvider == 'biganimal' && callRDSAPI == 4 && + } + {cloudProvider == 'azure' && callRDSAPI == 4 && + } + + + + + ); } @@ -359,5 +447,3 @@ CloudWizard.propTypes = { nodeInfo: PropTypes.object, nodeData: PropTypes.object, }; - - diff --git a/web/pgadmin/misc/cloud/static/js/azure.js b/web/pgadmin/misc/cloud/static/js/azure.js new file mode 100644 index 000000000..cf159328f --- /dev/null +++ b/web/pgadmin/misc/cloud/static/js/azure.js @@ -0,0 +1,298 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2022, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// +import React from 'react'; +import {AzureCredSchema, AzureClusterSchema, AzureDatabaseSchema} from './azure_schema.ui'; +import pgAdmin from 'sources/pgadmin'; +import { getNodeAjaxOptions, getNodeListById } from 'pgbrowser/node_ajax'; +import SchemaView from '../../../../static/js/SchemaView'; +import url_for from 'sources/url_for'; +import { isEmptyString } from 'sources/validators'; +import PropTypes from 'prop-types'; +import getApiInstance from '../../../../static/js/api_instance'; +import { CloudWizardEventsContext } from './CloudWizard'; +import {MESSAGE_TYPE } from '../../../../static/js/components/FormComponents'; +import gettext from 'sources/gettext'; + +// Azure credentials +export function AzureCredentials(props) { + const [cloudDBCredInstance, setCloudDBCredInstance] = React.useState(); + + var _eventBus = React.useContext(CloudWizardEventsContext); + React.useMemo(() => { + const azureCloudDBCredSchema = new AzureCredSchema({ + authenticateAzure:(auth_type, azure_tenant_id) => { + let loading_icon_url = url_for( + 'static', { 'filename': 'img/loading.gif'} + ); + const axiosApi = getApiInstance(); + _eventBus.fireEvent('SET_ERROR_MESSAGE_FOR_CLOUD_WIZARD', [MESSAGE_TYPE.INFO, 'Microsoft Azure authentication process is in progress..' + gettext('Loading...') + '']); + let _url = url_for('azure.verify_credentials'); + const post_data = { + cloud: 'azure', + secret: {'auth_type':auth_type, 'azure_tenant_id':azure_tenant_id} + }; + return new Promise((resolve, reject)=>{axiosApi.post(_url, post_data) + .then((res) => { + if (res.data && res.data.success == 1 ) { + _eventBus.fireEvent('SET_ERROR_MESSAGE_FOR_CLOUD_WIZARD',[MESSAGE_TYPE.SUCCESS, gettext('Authentication completed successfully. Click the Next button to proceed.')]); + _eventBus.fireEvent('SET_CRED_VERIFICATION_INITIATED',true); + resolve(true); + } + else if (res.data && res.data.success == 0) { + _eventBus.fireEvent('SET_ERROR_MESSAGE_FOR_CLOUD_WIZARD',[MESSAGE_TYPE.ERROR, res.data.errormsg]); + _eventBus.fireEvent('SET_CRED_VERIFICATION_INITIATED',false); + resolve(false); + } + }) + .catch((error) => { + _eventBus.fireEvent('SET_ERROR_MESSAGE_FOR_CLOUD_WIZARD',[MESSAGE_TYPE.ERROR, gettext(`Error while verification Microsoft Azure: ${error.response.data.errormsg}`)]); + reject(false); + });}); + } + }); + setCloudDBCredInstance(azureCloudDBCredSchema); + }, [props.cloudProvider]); + + return { /*This is intentional (SonarQube)*/ }} + viewHelperProps={{ mode: 'create' }} + schema={cloudDBCredInstance} + showFooter={false} + isTabView={false} + onDataChange={(isChanged, changedData) => { + props.setAzureCredData(changedData); + }} + />; +} +AzureCredentials.propTypes = { + nodeInfo: PropTypes.object, + nodeData: PropTypes.object, + cloudProvider: PropTypes.string, + setAzureCredData: PropTypes.func +}; + + +// Azure Instance +export function AzureInstanceDetails(props) { + const [azureInstanceSchema, setAzureInstanceSchema] = React.useState(); + + React.useMemo(() => { + const AzureSchema = new AzureClusterSchema({ + subscriptions: () => getNodeAjaxOptions('get_subscriptions', {}, {}, {},{ + useCache:false, + cacheNode: 'server', + customGenerateUrl: ()=>{ + return url_for('azure.subscriptions'); + } + }), + resourceGroups: (subscription)=>getNodeAjaxOptions('ge_resource_groups', pgAdmin.Browser.Nodes['server'], props.nodeInfo, props.nodeData, { + useCache:false, + cacheNode: 'server', + customGenerateUrl: ()=>{ + return url_for('azure.resource_groups', {'subscription_id': subscription}); + } + }), + regions: (subscription)=>getNodeAjaxOptions('get_regions', pgAdmin.Browser.Nodes['server'], props.nodeInfo, props.nodeData,{ + useCache:false, + cacheNode: 'server', + customGenerateUrl: ()=>{ + return url_for('azure.regions', {'subscription_id': subscription}); + } + }), + availabilityZones: (region)=>getNodeAjaxOptions('get_availability_zones', pgAdmin.Browser.Nodes['server'], props.nodeInfo, props.nodeData, { + useCache:false, + cacheNode: 'server', + customGenerateUrl: ()=>{ + return url_for('azure.availability_zones', {'region_name': region}); + } + }), + versionOptions: (availabilityZone)=>getNodeAjaxOptions('get_db_versions', pgAdmin.Browser.Nodes['server'], props.nodeInfo, props.nodeData, { + useCache:false, + cacheNode: 'server', + customGenerateUrl: ()=>{ + return url_for('azure.db_versions', {'availability_zone': availabilityZone}); + } + }), + instanceOptions: (dbVersion, availabilityZone)=>{ + if (isEmptyString(dbVersion) || isEmptyString(availabilityZone) ) return []; + return getNodeAjaxOptions('get_instance_types', pgAdmin.Browser.Nodes['server'], props.nodeInfo, props.nodeData, { + useCache:false, + cacheNode: 'server', + customGenerateUrl: ()=>{ + return url_for('azure.instance_types', {'availability_zone':availabilityZone, 'db_version': dbVersion}); + } + });}, + storageOptions: (dbVersion, availabilityZone)=>{ + if (isEmptyString(dbVersion) || isEmptyString(availabilityZone) ) return []; + return getNodeAjaxOptions('get_instance_types', pgAdmin.Browser.Nodes['server'], props.nodeInfo, props.nodeData, { + useCache:false, + cacheNode: 'server', + customGenerateUrl: ()=>{ + return url_for('azure.storage_types', {'availability_zone':availabilityZone, 'db_version': dbVersion}); + } + }); + }, + zoneRedundantHaSupported: (region)=>getNodeAjaxOptions('is_zone_redundant_ha_supported', pgAdmin.Browser.Nodes['server'], props.nodeInfo, props.nodeData,{ + useCache:false, + cacheNode: 'server', + customGenerateUrl: ()=>{ + return url_for('azure.zone_redundant_ha_supported', {'region_name': region}); + } + }), + }, { + nodeInfo: props.nodeInfo, + nodeData: props.nodeData, + hostIP: props.hostIP, + ...props.azureInstanceData + }); + setAzureInstanceSchema(AzureSchema); + }, [props.cloudProvider]); + + return { /*This is intentional (SonarQube)*/ }} + viewHelperProps={{ mode: 'create' }} + schema={azureInstanceSchema} + showFooter={false} + isTabView={false} + onDataChange={(isChanged, changedData) => { + props.setAzureInstanceData(changedData); + }} + />; +} +AzureInstanceDetails.propTypes = { + nodeInfo: PropTypes.object, + nodeData: PropTypes.object, + cloudProvider: PropTypes.string, + setAzureInstanceData: PropTypes.func, + hostIP: PropTypes.string, + subscriptions: PropTypes.array, + azureInstanceData: PropTypes.object +}; + + +// Azure Database Details +export function AzureDatabaseDetails(props) { + const [azureDBInstance, setAzureDBInstance] = React.useState(); + + React.useMemo(() => { + const azureDBSchema = new AzureDatabaseSchema({ + server_groups: ()=>getNodeListById(pgAdmin.Browser.Nodes['server_group'], props.nodeInfo, props.nodeData), + }, + { + gid: props.nodeInfo['server_group']._id, + } + ); + setAzureDBInstance(azureDBSchema); + + }, [props.cloudProvider]); + + return { /*This is intentional (SonarQube)*/ }} + viewHelperProps={{ mode: 'create' }} + schema={azureDBInstance} + showFooter={false} + isTabView={false} + onDataChange={(isChanged, changedData) => { + props.setAzureDatabaseData(changedData); + }} + />; +} +AzureDatabaseDetails.propTypes = { + nodeInfo: PropTypes.object, + nodeData: PropTypes.object, + cloudProvider: PropTypes.string, + setAzureDatabaseData: PropTypes.func, +}; + + +// Validation functions +export function validateAzureStep2(cloudInstanceDetails) { + let isError = false; + if (isEmptyString(cloudInstanceDetails.name) || + isEmptyString(cloudInstanceDetails.db_version) || isEmptyString(cloudInstanceDetails.instance_type) || + isEmptyString(cloudInstanceDetails.region)|| isEmptyString(cloudInstanceDetails.storage_size) || isEmptyString(cloudInstanceDetails.public_ips)) { + isError = true; + } + return isError; +} + +export function validateAzureStep3(cloudDBDetails, nodeInfo) { + let isError = false; + if (isEmptyString(cloudDBDetails.db_username) || isEmptyString(cloudDBDetails.db_password)) { + isError = true; + } + if (isEmptyString(cloudDBDetails.gid)) cloudDBDetails.gid = nodeInfo['server_group']._id; + return isError; +} + + +// Check cluster name avaiablity +export function checkClusternameAvailbility(clusterName){ + return new Promise((resolve, reject)=>{ + let _url = url_for('azure.check_cluster_name_availability'); + const axiosApi = getApiInstance(); + axiosApi.get(_url, { + params: { + 'name': clusterName, + } + }).then((res)=>{ + if (res.data) { + resolve(res.data); + } + }).catch((error) => { + reject(gettext(`Error while checking server name availability with Microsoft Azure: ${error.response.data.errormsg}`)); + }); + }); +} + +// Summary creation +function createData(name, value) { + if (typeof(value) == 'boolean') { + value = (value === true) ? 'True' : 'False'; + } + return { name, value }; +} + +// Summary section +export function getAzureSummary(cloud, cloudInstanceDetails, cloudDBDetails) { + const rows1 = [ + createData('Cloud', cloud), + createData('Subscription', cloudInstanceDetails.subscription), + createData('Resource group', cloudInstanceDetails.resource_group), + createData('Region', cloudInstanceDetails.region), + createData('Availability zone', cloudInstanceDetails.availability_zone), + ]; + + const rows2 = [ + createData('PostgreSQL version', cloudInstanceDetails.db_version), + createData('Instance type', cloudInstanceDetails.instance_type), + ]; + + const rows3 = [ + createData('Allocated storage', cloudInstanceDetails.storage_size + ' GiB'), + ]; + + const rows4 = [ + createData('Username', cloudDBDetails.db_username), + createData('Password', 'xxxxxxx'), + ]; + + const rows5 = [ + createData('Public IP', cloudInstanceDetails.public_ips), + ]; + + const rows6 = [ + createData('High availability', cloudInstanceDetails.high_availability), + ]; + + return [rows1, rows2, rows3, rows4, rows5, rows6]; +} diff --git a/web/pgadmin/misc/cloud/static/js/azure_schema.ui.js b/web/pgadmin/misc/cloud/static/js/azure_schema.ui.js new file mode 100644 index 000000000..22bfee7d8 --- /dev/null +++ b/web/pgadmin/misc/cloud/static/js/azure_schema.ui.js @@ -0,0 +1,683 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2022, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import gettext from 'sources/gettext'; +import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { isEmptyString } from 'sources/validators'; +import { CloudWizardEventsContext } from './CloudWizard'; +import React from 'react'; +import pgAdmin from 'sources/pgadmin'; + +class AzureCredSchema extends BaseUISchema { + constructor(fieldOptions = {}, initValues = {}) { + super({ + oid: null, + auth_type: 'interactive_browser_credential', + azure_tenant_id: '', + azure_subscription_id: '', + ...initValues, + }); + + this.fieldOptions = { + ...fieldOptions, + }; + + this.eventBus = React.useContext(CloudWizardEventsContext); + } + + get idAttribute() { + return 'oid'; + } + + validate(state, setErrMsg) { + let isError = false; + if (state.auth_type == 'interactive_browser_credential' && state.azure_tenant_id == '') { + isError = true; + setErrMsg( + 'azure_tenant_id', + gettext('Azure Tenant Id is required for Azure interactive authentication.') + ); + } + return isError; + } + + get baseFields() { + let obj = this; + return [ + { + id: 'auth_type', + label: gettext('Authenticate via'), + type: 'toggle', + mode: ['create'], + noEmpty: true, + options: [ + { + label: gettext('Interactive Browser'), + value: 'interactive_browser_credential', + }, + { + label: gettext('Azure CLI'), + value: 'azure_cli_credential', + }, + ], + disabled: pgAdmin.server_mode == 'True' ? true : false, + helpMessage: gettext( + 'Azure CLI will use the currently logged in identity through the Azure CLI on the local machine. Interactive Browser will open a browser window to authenticate a user interactively.' + ), + }, + { + id: 'azure_tenant_id', + label: gettext('Azure tenant id'), + type: 'text', + mode: ['create'], + deps: ['auth_type'], + helpMessage: gettext( + 'Enter the Azure tenant ID against which the user is authenticated.' + ), + disabled: (state) => { + return state.auth_type == 'interactive_browser_credential' + ? false + : true; + }, + depChange: (state) => { + if (state.auth_type == 'azure_cli_credential') { + state.azure_tenant_id = ''; + } + this.eventBus.fireEvent('SET_CRED_VERIFICATION_INITIATED', false); + this.eventBus.fireEvent('SET_ERROR_MESSAGE_FOR_CLOUD_WIZARD',['', '']); + }, + }, + { + id: 'auth_btn', + mode: ['create'], + deps: ['auth_type', 'azure_tenant_id'], + btnName: gettext('Click here to authenticate yourself to Microsoft Azure'), + type: (state) => { + return { + type: 'button', + onClick: () => { + obj.fieldOptions.authenticateAzure(state.auth_type, state.azure_tenant_id).then((res)=>{state._disabled_auth_btn= res;}); + }, + }; + }, + helpMessage: gettext( + 'After clicking the button above you will be redirected to the Microsoft Azure authentication page in a new browser tab if the Interactive Browser option is selected.' + ), + depChange: (state, source)=> { + if(source[0] == 'auth_type' || source[0] == 'azure_tenant_id'){ + state._disabled_auth_btn = false; + } + }, + disabled: (state)=> { + if(state.auth_type == 'interactive_browser_credential' && state.azure_tenant_id == ''){ + return true; + } + return state._disabled_auth_btn; + }, + }, + ]; + } +} + +class AzureProjectDetailsSchema extends BaseUISchema { + constructor(fieldOptions = {}, initValues = {}) { + super({ + oid: undefined, + subscription: '', + new_resource_group: false, + resource_group: '', + regions: '', + availability_zones: '', + high_availability: false, + ...initValues, + }); + + this.fieldOptions = { + ...fieldOptions, + }; + + this.initValues = initValues; + } + + get idAttribute() { + return 'oid'; + } + + get baseFields() { + return [ + { + id: 'subscription', + label: gettext('Subscription'), + mode: ['create'], + type: () => { + return { + type: 'select', + options: this.fieldOptions.subscriptions, + controlProps: { + allowClear: false, + filter: (options) => { + if (options.length == 0) return; + let _options = []; + options.forEach((option) => { + _options.push({ + label: + option.subscription_name + ' | ' + option.subscription_id, + value: option.subscription_id, + }); + }); + return _options; + }, + }, + }; + }, + }, + { + id: 'resource_group', + label: gettext('Resource group'), + mode: ['create'], + deps: ['subscription'], + type: (state) => { + return { + type: 'select', + options: state.subscription + ? () => this.fieldOptions.resourceGroups(state.subscription) + : [], + optionsReloadBasis: state.subscription, + controlProps: { + creatable: true, + allowClear: false, + }, + }; + }, + }, + { + id: 'region', + label: gettext('Location'), + mode: ['create'], + deps: ['subscription'], + type: (state) => { + return { + type: 'select', + options: state.subscription + ? () => this.fieldOptions.regions(state.subscription) + : [], + optionsReloadBasis: state.subscription, + allowClear: false, + }; + }, + }, + { + id: 'availability_zone', + label: gettext('Availability zone'), + deps: ['region'], + allowClear: false, + type: (state) => { + return { + type: 'select', + options: state.region + ? () => this.fieldOptions.availabilityZones(state.region) + : [], + optionsReloadBasis: state.region, + }; + }, + }, + ]; + } +} + +class AzureInstanceSchema extends BaseUISchema { + constructor(fieldOptions = {}, initValues = {}) { + super({ + db_version: '', + instance_type: '', + storage_size: '', + }); + + this.fieldOptions = { + ...fieldOptions, + }; + + this.initValues = initValues; + } + + get idAttribute() { + return 'oid'; + } + + get baseFields() { + return [ + { + id: 'db_version', + label: gettext('Database version'), + deps: ['availability_zone'], + type: (state) => { + return { + type: 'select', + options: state.availability_zone + ? () => this.fieldOptions.versionOptions(state.availability_zone) + : [], + optionsReloadBasis: state.availability_zone, + }; + }, + }, + { + id: 'db_instance_class', + label: gettext('Instance class'), + type: 'select', + options: [ + { + label: gettext('Burstable (1-2 vCores) '), + value: 'Burstable' }, + { + label: gettext('General Purpose (2-64 vCores)'), + value: 'GeneralPurpose', + }, + { + label: gettext('Memory Optimized (2-64 vCores)'), + value: 'MemoryOptimized', + }, + ], + }, + { + id: 'instance_type', + label: gettext('Instance type'), + deps: ['db_version', 'db_instance_class'], + depChange: (state, source)=>{ + if(source[0] == 'db_instance_class'){ + state.instance_type = undefined; + } + }, + type: (state) => { + return { + type: 'select', + options: () => this.fieldOptions.instanceOptions(state.db_version,state.availability_zone), + optionsReloadBasis: state.db_version + state.db_instance_class, + controlProps: { + allowClear: false, + filter: (options) => { + if (options.length == 0 || state.db_instance_class === undefined) + return; + let _options = []; + options.forEach((option) => { + if (option.type == state.db_instance_class) { + _options.push({ + label: option.label, + value: option.value, + }); + } + }); + return _options; + }, + }, + }; + }, + }, + { + id: 'storage_size', + label: gettext('Storage Size'), + deps: ['db_version', 'db_instance_class'], + type: (state) => { + return { + type: 'select', + options: () => this.fieldOptions.storageOptions(state.db_version, state.availability_zone), + optionsReloadBasis: state.db_version + (state.db_instance_class || 'Burstable'), + controlProps: { + allowClear: false, + filter: (opts) => { + if (opts.length == 0 || state.db_instance_class === undefined) + return; + let _options = []; + opts.forEach((opt) => { + if (opt.type == state.db_instance_class) { + _options.push({ + label: opt.label, + value: opt.value, + }); + } + }); + return _options; + }, + }, + }; + }, + }, + ]; + } +} + +class AzureDatabaseSchema extends BaseUISchema { + constructor(fieldOptions = {}, initValues = {}) { + super({ + oid: undefined, + gid: undefined, + db_username: '', + db_password: '', + db_confirm_password: '', + ...initValues, + }); + + this.fieldOptions = { + ...fieldOptions, + }; + } + + validateDbUserName(data, setErrMsg) { + if (data.db_username.length < 1 && data.db_username.length > 63 && !/^[A-Za-z0-9]*$/.test(data.db_username)) { + setErrMsg( + 'db_username', + gettext('Admin username must be more than 1 character & less than 63 and must only contains characters and numbers.') + ); + return true; + } + + if ( + ['azure_superuser', 'azure_pg_admin', 'admin', 'administrator', 'root', 'guest', 'public'].includes(data.db_username) || + data.db_username.startsWith('pg_')) { + setErrMsg('db_username', gettext('Specified Admin username is not allowed')); + return true; + } + return false; + } + + validateDbPassword(data, setErrMsg) { + if ( + !isEmptyString(data.db_password) && + !isEmptyString(data.db_confirm_password) && + data.db_password != data.db_confirm_password + ) { + setErrMsg('db_confirm_password', gettext('Passwords do not match.')); + return true; + } + if (!isEmptyString(data.db_confirm_password) && !/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[#$@!%&*?])[A-Za-z\d#$@!%&*?]{8,128}$/.test(data.db_confirm_password)) { + setErrMsg( + 'db_confirm_password', + gettext( + 'The password must be 8-128 characters long and must contain characters from three of the following categories - English uppercase letters, English lowercase letters, numbers (0-9), and non-alphanumeric characters (!, $, #, %, etc.)' + ) + ); + return true; + } + return false; + } + + validate(data, setErrMsg) { + if (this.validateDbUserName(data, setErrMsg) || this.validateDbPassword(data, setErrMsg)) { + return true; + } + return false; + } + + get baseFields() { + return [ + { + id: 'gid', + label: gettext('pgAdmin server group'), + type: 'select', + options: this.fieldOptions.server_groups, + mode: ['create'], + controlProps: { allowClear: false }, + noEmpty: true, + }, + { + id: 'db_username', + label: gettext('Admin username'), + type: 'text', + mode: ['create'], + noEmpty: true, + helpMessage: gettext( + 'The admin username must be 1-63 characters long and can only contain character, numbers and the underscore character. The username cannot be "azure_superuser", "azure_pg_admin", "admin", "administrator", "root", "guest", "public", or start with "pg_".' + ), + }, + { + id: 'db_password', + label: gettext('Password'), + type: 'password', + mode: ['create'], + noEmpty: true, + helpMessage: gettext( + 'The password must be 8-128 characters long and must contain characters from three of the following categories - English uppercase letters, English lowercase letters, numbers (0-9), and non-alphanumeric characters (!, $, #, %, etc.), and cannot contain all or part of the login name' + ), + }, + { + id: 'db_confirm_password', + label: gettext('Confirm password'), + type: 'password', + mode: ['create'], + noEmpty: true, + }, + ]; + } +} + +class AzureNetworkSchema extends BaseUISchema { + constructor() { + super(); + } + + get baseFields() { + return [ + { + id: 'public_ips', + label: gettext('Public IP range'), + type: 'text', + mode: ['create'], + helpMessage: gettext( + 'List of IP Addresses or range of IP Addresses (start IP Address - end IP address) from which inbound traffic should be accepted. Add multiple IP addresses/ranges separated with commas, for example: "192.168.0.50, 192.168.0.100 - 192.168.0.200"' + ), + }, + ]; + } +} + +class AzureHighAvailabilitySchema extends BaseUISchema { + constructor(fieldOptions = {}, initValues = {}) { + super({ + oid: undefined, + high_availability: false, + ...initValues, + }); + + this.fieldOptions = { + ...fieldOptions, + }; + this.initValues = initValues; + } + + get idAttribute() { + return 'oid'; + } + + get baseFields() { + return [ + { + id: 'high_availability', + label: gettext('Zone redundant high availability'), + type: 'switch', + mode: ['create'], + deps: ['region', 'db_instance_class'], + depChange: (state, source, topState, actionObj) => { + state._is_zone_redundant_ha_supported = false; + if (state.region != actionObj.oldState.region) { + state.high_availability = false; + this.fieldOptions + .zoneRedundantHaSupported(state.region) + .then((res) => { + state._is_zone_redundant_ha_supported = res.is_zone_redundant_ha_supported; + }); + } + if (state.db_instance_class != 'Burstable') { + state._is_zone_redundant_ha_supported = true; + } + }, + disabled: (state) => { + if (isEmptyString(state.region) || state.db_instance_class == 'Burstable') { + state.high_availability = false; + return true; + } else { + return !state._is_zone_redundant_ha_supported; + } + }, + helpMessage: gettext( + 'Zone redundant high availability deploys a standby replica in a different zone. The Burstable instance type does not support high availability.' + ), + }, + ]; + } +} + +class AzureClusterSchema extends BaseUISchema { + constructor(fieldOptions = {}, initValues = {}) { + super({ + oid: undefined, + name: '', + // Need to initilize child class init values in parent class itself + public_ips: initValues?.hostIP.split('/')[0], + db_instance_class: 'Burstable', + ...initValues, + }); + + this.fieldOptions = { + ...fieldOptions, + }; + this.initValues = initValues; + + this.azureProjectDetails = new AzureProjectDetailsSchema( + { + subscriptions: this.fieldOptions.subscriptions, + resourceGroups: this.fieldOptions.resourceGroups, + regions: this.fieldOptions.regions, + availabilityZones: this.fieldOptions.availabilityZones, + }, + {} + ); + + this.azureInstanceDetails = new AzureInstanceSchema( + { + versionOptions: this.fieldOptions.versionOptions, + instanceOptions: this.fieldOptions.instanceOptions, + storageOptions: this.fieldOptions.storageOptions, + }, + {} + ); + + this.azureNetworkSchema = new AzureNetworkSchema({}, {}); + + this.azureHighAvailabilitySchema = new AzureHighAvailabilitySchema( + { + zoneRedundantHaSupported: this.fieldOptions.zoneRedundantHaSupported, + }, + {} + ); + } + + get idAttribute() { + return 'oid'; + } + + get baseFields() { + return [ + { + id: 'name', + label: gettext('Cluster name'), + type: 'text', + mode: ['create'], + noEmpty: true, + }, + { + type: 'nested-fieldset', + label: gettext('Project Details'), + mode: ['create'], + schema: this.azureProjectDetails, + }, + { + type: 'nested-fieldset', + label: gettext('Version & Instance'), + mode: ['create'], + schema: this.azureInstanceDetails, + }, + { + type: 'nested-fieldset', + label: gettext('Network Connectivity'), + mode: ['create'], + schema: this.azureNetworkSchema, + }, + { + type: 'nested-fieldset', + label: gettext('Availability'), + mode: ['create'], + schema: this.azureHighAvailabilitySchema, + }, + ]; + } + + validateProjectDetails(data, setErr){ + if(isEmptyString(data.subscription)){ + setErr('subscription',gettext('Subscription cannot be empty.')); + return true; + } + + if(isEmptyString(data.resource_group)){ + setErr('resource_group',gettext('Resource group cannot be empty.')); + return true; + } + + if(isEmptyString(data.region)){ + setErr('region',gettext('Location cannot be empty.')); + return true; + } + } + + validateInstanceDetails(data, setErr){ + if(isEmptyString(data.availability_zone)){ + setErr('availability_zone',gettext('Availability zone cannot be empty.')); + return true; + } + + if(isEmptyString(data.db_version)){ + setErr('db_version',gettext('Database version cannot be empty.')); + return true; + } + + if(isEmptyString(data.db_instance_class)){ + setErr('db_instance_class',gettext('Instance class cannot be empty.')); + return true; + } + } + + validateNetworkDetails(data, setErr){ + if(isEmptyString(data.instance_type)){ + setErr('instance_type',gettext('Instance type cannot be empty.')); + return true; + } + + if(isEmptyString(data.storage_size)){ + setErr('storage_size',gettext('Storage size cannot be empty.')); + return true; + } + + if(isEmptyString(data.public_ips)){ + setErr('public_ips',gettext('Public IP range cannot be empty.')); + return true; + } + } + + validate(data, setErr) { + if ( !isEmptyString(data.name) && (!/^[a-z0-9\-]*$/.test(data.name) || data.name.length < 3)) { + setErr('name',gettext('Name must be more than 2 characters or more & must only contain lowercase letters, numbers, and hyphens')); + return true; + } + + if(this.validateProjectDetails(data, setErr) || this.validateInstanceDetails(data, setErr) || this.validateNetworkDetails(data, setErr)){ + return true; + } + return false; + } +} + +export { AzureCredSchema, AzureClusterSchema, AzureDatabaseSchema }; diff --git a/web/pgadmin/misc/cloud/static/js/cloud.js b/web/pgadmin/misc/cloud/static/js/cloud.js index 22f74ce6d..ac1711fd8 100644 --- a/web/pgadmin/misc/cloud/static/js/cloud.js +++ b/web/pgadmin/misc/cloud/static/js/cloud.js @@ -10,6 +10,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import Theme from 'sources/Theme'; import CloudWizard from './CloudWizard'; +import getApiInstance from '../../../../static/js/api_instance'; // Cloud Wizard @@ -124,6 +125,15 @@ define('pgadmin.misc.cloud', [ hooks: { // Triggered when the dialog is closed onclose: function () { + if(event.target instanceof Object){ + const axiosApi = getApiInstance(); + let _url = url_for('cloud.clear_cloud_session'); + axiosApi.post(_url) + .then(() => {}) + .catch((error) => { + Alertify.error(gettext(`Error while clearing cloud wizard data: ${error.response.data.errormsg}`)); + }); + } // Clear the view and remove the react component. return setTimeout((function () { ReactDOM.unmountComponentAtNode(document.getElementById('cloudWizardDlg')); diff --git a/web/pgadmin/misc/cloud/static/js/cloud_components.jsx b/web/pgadmin/misc/cloud/static/js/cloud_components.jsx index 742c402b7..3a799cef7 100644 --- a/web/pgadmin/misc/cloud/static/js/cloud_components.jsx +++ b/web/pgadmin/misc/cloud/static/js/cloud_components.jsx @@ -14,6 +14,7 @@ import { DefaultButton, PrimaryButton } from '../../../../static/js/components/B import { makeStyles } from '@material-ui/core/styles'; import PropTypes from 'prop-types'; import { getAWSSummary } from './aws'; +import {getAzureSummary} from './azure'; import { getBigAnimalSummary } from './biganimal'; import { commonTableStyles } from '../../../../static/js/Theme'; import { Table, TableBody, TableCell, TableHead, TableRow } from '@material-ui/core'; @@ -70,7 +71,10 @@ export function FinalSummary(props) { if (props.cloudProvider == 'biganimal') { summary = getBigAnimalSummary(props.cloudProvider, props.instanceData, props.databaseData); summaryHeader[1] = 'Version Details'; - } else { + } else if(props.cloudProvider == 'azure'){ + summaryHeader.push('Network Connectivity','Availability'); + summary = getAzureSummary(props.cloudProvider, props.instanceData, props.databaseData); + }else { summary = getAWSSummary(props.cloudProvider, props.instanceData, props.databaseData); } diff --git a/web/pgadmin/misc/cloud/utils/__init__.py b/web/pgadmin/misc/cloud/utils/__init__.py index abaa92283..6d91ad8c3 100644 --- a/web/pgadmin/misc/cloud/utils/__init__.py +++ b/web/pgadmin/misc/cloud/utils/__init__.py @@ -20,10 +20,10 @@ def get_my_ip(): """ Return the public IP of this host """ http = urllib3.PoolManager() try: - external_ip = http.request('GET', 'http://ident.me').data + external_ip = http.request('GET', 'http://ifconfig.me/ip').data except Exception: try: - external_ip = http.request('GET', 'http://ifconfig.me/ip').data + external_ip = http.request('GET', 'http://ident.me').data except Exception: external_ip = '127.0.0.1' diff --git a/web/pgadmin/static/img/azure.svg b/web/pgadmin/static/img/azure.svg new file mode 100644 index 000000000..445315a5d --- /dev/null +++ b/web/pgadmin/static/img/azure.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/pgadmin/static/js/SchemaView/MappedControl.jsx b/web/pgadmin/static/js/SchemaView/MappedControl.jsx index 4a0f26746..0cfd3abaa 100644 --- a/web/pgadmin/static/js/SchemaView/MappedControl.jsx +++ b/web/pgadmin/static/js/SchemaView/MappedControl.jsx @@ -12,7 +12,7 @@ import _ from 'lodash'; import { FormInputText, FormInputSelect, FormInputSwitch, FormInputCheckbox, FormInputColor, FormInputFileSelect, FormInputToggle, InputSwitch, FormInputSQL, InputSQL, FormNote, FormInputDateTimePicker, PlainString, - InputSelect, InputText, InputCheckbox, InputDateTimePicker, InputFileSelect, FormInputKeyboardShortcut, FormInputQueryThreshold, FormInputSelectThemes, InputRadio + InputSelect, InputText, InputCheckbox, InputDateTimePicker, InputFileSelect, FormInputKeyboardShortcut, FormInputQueryThreshold, FormInputSelectThemes, InputRadio, FormButton } from '../components/FormComponents'; import Privilege from '../components/Privilege'; import { evalFunc } from 'sources/utils'; @@ -21,7 +21,7 @@ import CustomPropTypes from '../custom_prop_types'; import { SelectRefresh } from '../components/SelectRefresh'; /* Control mapping for form view */ -function MappedFormControlBase({ type, value, id, onChange, className, visible, inputRef, noLabel, ...props }) { +function MappedFormControlBase({ type, value, id, onChange, className, visible, inputRef, noLabel, onClick, ...props }) { const name = id; const onTextChange = useCallback((e) => { let val = e; @@ -86,6 +86,8 @@ function MappedFormControlBase({ type, value, id, onChange, className, visible, return ; case 'theme': return ; + case 'button': + return ; default: return ; } @@ -103,7 +105,8 @@ MappedFormControlBase.propTypes = { ]), visible: PropTypes.bool, inputRef: CustomPropTypes.ref, - noLabel: PropTypes.bool + noLabel: PropTypes.bool, + onClick: PropTypes.func }; /* Control mapping for grid cell view */ @@ -197,11 +200,11 @@ const ALLOWED_PROPS_FIELD_COMMON = [ 'mode', 'value', 'readonly', 'disabled', 'hasError', 'id', 'label', 'options', 'optionsLoaded', 'controlProps', 'schema', 'inputRef', 'visible', 'autoFocus', 'helpMessage', 'className', 'optionsReloadBasis', - 'orientation', 'isvalidate', 'fields', 'radioType', 'hideBrowseButton' + 'orientation', 'isvalidate', 'fields', 'radioType', 'hideBrowseButton', 'btnName' ]; const ALLOWED_PROPS_FIELD_FORM = [ - 'type', 'onChange', 'state', 'noLabel', 'text', + 'type', 'onChange', 'state', 'noLabel', 'text','onClick' ]; const ALLOWED_PROPS_FIELD_CELL = [ diff --git a/web/pgadmin/static/js/components/ExternalIcon.jsx b/web/pgadmin/static/js/components/ExternalIcon.jsx index 13e986201..b5c0fee90 100644 --- a/web/pgadmin/static/js/components/ExternalIcon.jsx +++ b/web/pgadmin/static/js/components/ExternalIcon.jsx @@ -16,6 +16,7 @@ import Expand from '../../img/fonticon/open_in_full.svg?svgr'; import Collapse from '../../img/fonticon/close_fullscreen.svg?svgr'; import AWS from '../../img/aws.svg?svgr'; import BigAnimal from '../../img/biganimal.svg?svgr'; +import Azure from '../../img/azure.svg?svgr'; export default function ExternalIcon({Icon, ...props}) { return ; @@ -72,3 +73,6 @@ AWSIcon.propTypes = {style: PropTypes.object}; export const BigAnimalIcon = ({style})=>; BigAnimalIcon.propTypes = {style: PropTypes.object}; + +export const AzureIcon = ({style})=>; +AzureIcon.propTypes = {style: PropTypes.object}; \ No newline at end of file diff --git a/web/pgadmin/static/js/components/FormComponents.jsx b/web/pgadmin/static/js/components/FormComponents.jsx index 28812238a..0f85d0fe0 100644 --- a/web/pgadmin/static/js/components/FormComponents.jsx +++ b/web/pgadmin/static/js/components/FormComponents.jsx @@ -1289,3 +1289,22 @@ NotifierMessage.propTypes = { closable: PropTypes.bool, onClose: PropTypes.func, }; + + +export function FormButton({required, label, + className, helpMessage, onClick, disabled, ...props }) { + return ( + + {gettext(props.btnName)} + + ); +} +FormButton.propTypes = { + required: PropTypes.bool, + label: PropTypes.string, + className: CustomPropTypes.className, + helpMessage: PropTypes.string, + onClick: PropTypes.func, + disabled: PropTypes.bool, + btnName: PropTypes.string +};