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..fcbbdde46 --- /dev/null +++ b/web/pgacloud/providers/azure.py @@ -0,0 +1,332 @@ +########################################################################## +# +# 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 +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 + + 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=True, + 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('--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)) + + # 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) + ), + administrator_login=args.db_username, + administrator_login_password=args.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) + firewall_rules = 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..810a65709 --- /dev/null +++ b/web/pgadmin/misc/cloud/azure/__init__.py @@ -0,0 +1,646 @@ +# ########################################################################## +# # +# # 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.identity import AuthenticationRecord +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.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: + 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=['POST'], endpoint='check_cluster_name_availability') +@login_required +def check_cluster_name_availability(): + """Check Server Name availability.""" + data = json.loads(request.data, encoding='utf-8') + azure = session['azure']['azure_obj'] + server_name_available, error = \ + azure.check_cluster_name_availability(data['name']) + return make_json_response(success=server_name_available, 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('/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 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']), + '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, + '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'] + + 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-password', + data['db_details']['db_password'], + + '--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'])] + + _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'] + + 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 7c191b95d..da5391810 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, 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; }; @@ -213,6 +262,7 @@ export default function CloudWizard({ nodeInfo, nodeData }) { }); } else { + setErrMsg(['', '']); resolve(); } }); @@ -262,96 +312,122 @@ 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 any option to deploy on cloud.')} + + + }, {label: 'EDB BigAnimal', value: 'biganimal', icon: }, {'label': 'Azure Postgresql', value: 'azure', 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 == '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 +435,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..559caa347 --- /dev/null +++ b/web/pgadmin/misc/cloud/static/js/azure.js @@ -0,0 +1,221 @@ +///////////////////////////////////////////////////////////// +// +// 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'; + +// Azure credentials +export function AzureCredentials(props) { + const [cloudDBCredInstance, setCloudDBCredInstance] = React.useState(); + + React.useMemo(() => { + const azureCloudDBCredSchema = new AzureCredSchema({}); + 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)=>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)=>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}); + } + }) + }, { + 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; +} + +// Summary creation +function createData(name, value) { + 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), + createData('Public IP', cloudInstanceDetails.public_ips), + ]; + + 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'), + ]; + + return [rows1, rows2, rows3, rows4]; +} \ No newline at end of file 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..2419aaebb --- /dev/null +++ b/web/pgadmin/misc/cloud/static/js/azure_schema.ui.js @@ -0,0 +1,495 @@ +///////////////////////////////////////////////////////////// +// +// 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 url_for from 'sources/url_for'; +import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { isEmptyString } from 'sources/validators'; +import getApiInstance from '../../../../static/js/api_instance'; +import {MESSAGE_TYPE } from '../../../../static/js/components/FormComponents'; +import { CloudWizardEventsContext } from './CloudWizard'; +import React from 'react'; + + +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, setError){ + let isError = false; + if (state.cloud_type == 'interactive_browser_credential' && state.azure_tenant_id == ''){ + isError = true; + setError('azure_tenant_id', 'Azure Tenant Id is required for Azure interactive authentication.'); + } + return isError; + } + + authenticateAzure = (auth_type, azure_tenant_id) => { + let loading_icon_url = url_for( + 'static', { 'filename': 'img/loading.gif'} + ); + const axiosApi = getApiInstance(); + this.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} + }; + axiosApi.post(_url, post_data) + .then((res) => { + if (res.data && res.data.success == 1 ) { + this.eventBus.fireEvent('SET_ERROR_MESSAGE_FOR_CLOUD_WIZARD',[MESSAGE_TYPE.SUCCESS, 'Authentication completed successfully. Click the Next button to proceed.']); + this.eventBus.fireEvent('SET_CRED_VERIFICATION_INITIATED',true); + } + else if (res.data && res.data.success == 0) { + this.eventBus.fireEvent('SET_ERROR_MESSAGE_FOR_CLOUD_WIZARD',[MESSAGE_TYPE.ERROR, res.data.errormsg]); + this.eventBus.fireEvent('SET_CRED_VERIFICATION_INITIATED',false); + } + }) + .catch((error) => { + this.eventBus.fireEvent('SET_ERROR_MESSAGE_FOR_CLOUD_WIZARD',[MESSAGE_TYPE.ERROR, gettext(`Error while verification Microsoft Azure: ${error.response.data.errormsg}`)]); + }); + }; + + get baseFields() { + let obj = this; + return [ + { + id: 'auth_type', label: gettext('Authenticate via'), type: 'toggle', + mode: ['create'], + options: [ + {'label': gettext('Azure CLI'), 'value': 'azure_cli_credential'}, + {'label': gettext('Interactive Browser'), 'value': 'interactive_browser_credential'}, + ], noEmpty: true, + helpMessage: gettext('"Azure CLI" will use the Azure CLIs currently logged in identity."Interactive Browser" opens a browser to interactively authenticate a user.') + }, + { + id: 'azure_tenant_id', label: gettext('Azure Tenant Id'), type: 'text', + mode: ['create'], deps: ['auth_type'], + helpMessage: gettext('Enter Azure tenant id agianse which 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); + } + },{ + id:'auth_btn', + mode:['create'], deps: ['auth_type', 'azure_tenant_id'], + btnName:'Click here to authenticate yourself to Microsoft Azure', + type:(state)=>{ + return{ + type: 'button', + onClick:()=> {obj.authenticateAzure(state.auth_type, state.azure_tenant_id);}, + }; + }, + helpMessage: gettext('By clicking the below button, you will be redirected to the Microsoft Azure authentication page in a new tab if Authentication Via Interactive Browser option is selected.'), + disabled: (state) => {return state.auth_type == 'interactive_browser_credential' && state.azure_tenant_id == '' ? true : false;} + } + ]; + } +} + +class AzureProjectDetailsSchema extends BaseUISchema { + constructor(fieldOptions = {}, initValues={}) { + super({ + oid: undefined, + subscription: '', + new_resource_group: false, + resource_group: '', + regions:'', + availability_zones:'', + ...initValues + }); + + this.fieldOptions = { + ...fieldOptions, + }; + + this.initValues = initValues; + } + + get idAttribute() { + return 'oid'; + } + + get baseFields() { + return [ + { + id: 'subscription', label: gettext('Subscription'), + mode: ['create'],noEmpty: true, + 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'], + noEmpty: true, + 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'], + noEmpty: true, + 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'], noEmpty: true,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'],noEmpty: true, + 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: 'toggle', + 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'}, + ], noEmpty: true, orientation: 'vertical', + },{ + id: 'instance_type', label: gettext('Instance type'), + deps: ['db_version', 'db_instance_class'],noEmpty: true, + type: (state) => { + return { + type: 'select', + options: ()=> state.db_version ? this.fieldOptions.instanceOptions(state.db_version, state.availability_zone):[], + optionsReloadBasis: state.db_version + (state.db_instance_class || 'Burstable'), + controlProps: { + allowClear: false, + filter: (options) => { + if (options.length == 0) return; + if (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: ()=> state.db_version ? this.fieldOptions.storageOptions(state.db_version, state.availability_zone):[], + optionsReloadBasis: state.db_version + (state.db_instance_class || 'Burstable'), + 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; + }, + } + }; + }, + noEmpty: true, + } + ]; + } +} + +class AzureDatabaseSchema extends BaseUISchema { + constructor(fieldOptions = {}, initValues={}) { + super({ + oid: undefined, + gid: undefined, + db_name: '', + db_username: '', + db_password: '', + db_confirm_password: '', + ...initValues, + }); + + this.fieldOptions = { + ...fieldOptions, + }; + + } + + validate(data, setErrMsg) { + if(!isEmptyString(data.db_username)){ + if( data.db_username.length > 1 && data.db_username.length > 63){ + setErrMsg('db_username', 'Admin username must be more than 1 character & less than 63.'); + return true; + } + + if(!(/^[A-Za-z0-9]*$/.test(data.db_username))){ + setErrMsg('db_username', 'Admin username must only contain 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', 'Specified Admin username is not allowed'); + return true; + } + } + + 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) && data.db_confirm_password.length < 8) { + setErrMsg('db_confirm_password', gettext('Password must be 8 characters or more.')); + return true; + } + if (data.db_confirm_password.includes('\'') || data.db_confirm_password.includes('"') || + data.db_confirm_password.includes('@') || data.db_confirm_password.includes('/')) { + setErrMsg('db_confirm_password', gettext('Invalid passowrd.')); + return true; + } + + return false; + } + + get idAttribute() { + return 'oid'; + } + + get baseFields() { + return [{ + id: 'gid', label: gettext('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('Admin username must be 1-63 characters and can only contain characters and numbers.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('Password must be 8-128 characters 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/ range of IP Addresess (start IP Address - end IP address) for permitting the inbound traffic.Add multiple ip addresses/ranges by comma separated. Ex: 127.0.0.1, 127.0.0.1 - 127.0.0.4. Add 0.0.0.0 - 255.255.255.255 for publically accessible.'), + }, + ]; + } +} + +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.eventBus = React.useContext(CloudWizardEventsContext); + + 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({}, + {}); + } + + validate (data, setErr) { + if (!isEmptyString(data.name) && (/[A-Z]/.test(data.name) || data.name.length < 3)) { + setErr('name', gettext('Name must be more than 3 characters or more & Shoudld not have capital letter')); + return true; + } + + let _url = url_for('azure.check_cluster_name_availability'); + const post_data = { + name: data.name, + }; + const axiosApi = getApiInstance(); + axiosApi.post(_url, post_data) + .then((res) => { + if (res.data && res.data.success == 0 ) { + setErr('name', gettext('Cluster name is already is use.')); + return true; + } + }) + .catch((error) => { + this.eventBus.fireEvent('SET_ERROR_MESSAGE_FOR_CLOUD_WIZARD',[MESSAGE_TYPE.ERROR, gettext(`Error while chekcing server name availability with Microsoft Azure: ${error.response.data.errormsg}`)]); + }); + return false; + } + + 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 + } + ]; + } +} + +export { + AzureCredSchema, + AzureClusterSchema, + AzureDatabaseSchema +}; \ No newline at end of file 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..de0a56b8b 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,9 @@ 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'){ + 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/SchemaView/index.jsx b/web/pgadmin/static/js/SchemaView/index.jsx index ca8ed1f4b..4e6fbeacb 100644 --- a/web/pgadmin/static/js/SchemaView/index.jsx +++ b/web/pgadmin/static/js/SchemaView/index.jsx @@ -485,6 +485,10 @@ function SchemaDialogView({ setDirty(isDataChanged); /* tell the callbacks the data has changed */ + if(viewHelperProps.mode !== 'edit') { + /* If new then merge the changed data with origData */ + changedData = _.assign({}, schema.origData, changedData); + } props.onDataChange && props.onDataChange(isDataChanged, changedData); }, [sessData, formReady]); @@ -597,9 +601,8 @@ function SchemaDialogView({ setLoaderText('Saving...'); /* Get the changed data */ let changeData = getChangedData(schema, viewHelperProps, sessData); - /* Add the id when in edit mode */ - if(viewHelperProps.mode !== 'edit') { + if(viewHelperProps.mode !== 'edit'){ /* If new then merge the changed data with origData */ changeData = _.assign({}, schema.origData, changeData); } else { 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 +};