diff --git a/src/serviceconnector-passwordless/HISTORY.rst b/src/serviceconnector-passwordless/HISTORY.rst index c108ca39d60..f30cad14546 100644 --- a/src/serviceconnector-passwordless/HISTORY.rst +++ b/src/serviceconnector-passwordless/HISTORY.rst @@ -2,6 +2,10 @@ Release History =============== +3.2.0 +++++++ +* Introduce support for Fabric SQL as a target service. Introduce new `connstr_props` argument to configure Fabric SQL. + 3.1.3 ++++++ * Fix argument missing diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_client_factory.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_client_factory.py index c8331e9b7c2..28505175277 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_client_factory.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_client_factory.py @@ -13,7 +13,7 @@ def cf_connection_cl(cli_ctx, *_): os.environ['AZURE_HTTP_USER_AGENT'] = (os.environ.get('AZURE_HTTP_USER_AGENT') or '') + " CliExtension/{}({})".format(NAME, VERSION) return get_mgmt_service_client(cli_ctx, ServiceLinkerManagementClient, - subscription_bound=False, api_version="2023-04-01-preview") + subscription_bound=False, api_version="2024-07-01-preview") def cf_linker(cli_ctx, *_): diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py index 11f61fa5ee0..1265716de8a 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py @@ -7,6 +7,7 @@ import struct import sys import re +import requests from knack.log import get_logger from azure.mgmt.core.tools import parse_resource_id from azure.cli.core import telemetry @@ -46,7 +47,7 @@ # For db(mysqlFlex/psql/psqlFlex/sql) linker with auth type=systemAssignedIdentity, enable Microsoft Entra auth and create db user on data plane # For other linker, ignore the steps def get_enable_mi_for_db_linker_func(yes=False, new=False): - def enable_mi_for_db_linker(cmd, source_id, target_id, auth_info, client_type, connection_name, *args, **kwargs): + def enable_mi_for_db_linker(cmd, source_id, target_id, auth_info, client_type, connection_name, connstr_props, *args, **kwargs): # return if connection is not for db mi if auth_info['auth_type'] not in [AUTHTYPES[AUTH_TYPE.SystemIdentity], AUTHTYPES[AUTH_TYPE.UserIdentity], @@ -61,7 +62,7 @@ def enable_mi_for_db_linker(cmd, source_id, target_id, auth_info, client_type, c if source_handler is None: return None target_handler = getTargetHandler( - cmd, target_id, target_type, auth_info, client_type, connection_name, skip_prompt=yes, new_user=new) + cmd, target_id, target_type, auth_info, client_type, connection_name, connstr_props, skip_prompt=yes, new_user=new) if target_handler is None: return None target_handler.check_db_existence() @@ -88,7 +89,7 @@ def enable_mi_for_db_linker(cmd, source_id, target_id, auth_info, client_type, c source_object_id = source_handler.get_identity_pid() target_handler.identity_object_id = source_object_id try: - if target_type in [RESOURCE.Sql]: + if target_type in [RESOURCE.Sql, RESOURCE.FabricSql]: target_handler.identity_name = source_handler.get_identity_name() elif target_type in [RESOURCE.Postgres, RESOURCE.MysqlFlexible]: identity_info = run_cli_cmd( @@ -149,7 +150,7 @@ def enable_mi_for_db_linker(cmd, source_id, target_id, auth_info, client_type, c # pylint: disable=unused-argument, too-many-instance-attributes -def getTargetHandler(cmd, target_id, target_type, auth_info, client_type, connection_name, skip_prompt, new_user): +def getTargetHandler(cmd, target_id, target_type, auth_info, client_type, connection_name, connstr_props, skip_prompt, new_user): if target_type in {RESOURCE.Sql}: return SqlHandler(cmd, target_id, target_type, auth_info, connection_name, skip_prompt, new_user) if target_type in {RESOURCE.Postgres}: @@ -158,6 +159,8 @@ def getTargetHandler(cmd, target_id, target_type, auth_info, client_type, connec return PostgresFlexHandler(cmd, target_id, target_type, auth_info, connection_name, skip_prompt, new_user) if target_type in {RESOURCE.MysqlFlexible}: return MysqlFlexibleHandler(cmd, target_id, target_type, auth_info, connection_name, skip_prompt, new_user) + if target_type in {RESOURCE.FabricSql}: + return FabricSqlHandler(cmd, target_id, target_type, auth_info, connection_name, connstr_props, skip_prompt, new_user) return None @@ -960,6 +963,89 @@ def get_create_query(self): ] +class FabricSqlHandler(SqlHandler): + def __init__(self, cmd, target_id, target_type, auth_info, connection_name, connstr_props, skip_prompt, new_user): + super().__init__(cmd, target_id, target_type, + auth_info, connection_name, skip_prompt, new_user) + + self.target_id = target_id + + if not connstr_props: + raise CLIInternalError("Missing additional connection string properties for Fabric SQL target.") + + Server = connstr_props.get('Server') or connstr_props.get('Data Source') + Database = connstr_props.get('Database') or connstr_props.get('Initial Catalog') + if not Server or not Database: + raise CLIInternalError("Missing 'Server' or 'Database' in additonal connection string properties keys." + "Use --connstr_props 'Server=xxx' 'Database=xxx' to provide the values.") + + # Construct the ODBC connection string + self.ODBCConnectionString = self.construct_odbc_connection_string(Server, Database) + logger.warning("ODBC connection string: %s", self.ODBCConnectionString) + + def check_db_existence(self): + fabric_token = self.get_fabric_access_token() + headers = {"Authorization": "Bearer {}".format(fabric_token)} + response = requests.get(self.target_id, headers=headers) + + if response: + response_json = response.json() + if response_json["id"]: + return + + e = ResourceNotFoundError("No database found with name {}".format(self.dbname)) + telemetry.set_exception(e, "No-Db") + raise e + + def construct_odbc_connection_string(self, server, database): + # Map fields to ODBC fields + odbc_dict = { + 'Driver': '{driver}', + 'Server': server, + 'Database': database, + } + + odbc_connection_string = ';'.join([f'{key}={value}' for key, value in odbc_dict.items()]) + return odbc_connection_string + + def create_aad_user(self): + query_list = self.get_create_query() + connection_args = self.get_connection_string() + + logger.warning("Connecting to database...") + self.create_aad_user_in_sql(connection_args, query_list) + + def get_fabric_access_token(self): + return run_cli_cmd('az account get-access-token --output json --resource https://api.fabric.microsoft.com/').get('accessToken') + + def set_user_admin(self, user_object_id, **kwargs): + return + + def get_connection_string(self, dbname=""): + token_bytes = self.get_fabric_access_token().encode('utf-16-le') + + token_struct = struct.pack( + f'," "Database=".', + 'placeholder': 'Server=MyServer,1433 Database=MyDB' + } + } } AUTH_TYPE_PARAMS = { diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/commands.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/commands.py index 84f1003d750..2eeccb135ff 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/commands.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/commands.py @@ -7,6 +7,7 @@ from azure.cli.core.commands import CliCommandType from azure.cli.command_modules.serviceconnector._resource_config import ( + RESOURCE, SOURCE_RESOURCES, TARGET_RESOURCES_DEPRECATED ) @@ -31,6 +32,9 @@ def load_command_table(self, _): client_factory=cf_connector) for target in PASSWORDLESS_TARGET_RESOURCES: + # FabricSql is not supported for Local Connector + if target == RESOURCE.FabricSql: + continue with self.command_group('connection create', local_connection_type, client_factory=cf_connector) as ig: if target in TARGET_RESOURCES_DEPRECATED: @@ -45,6 +49,8 @@ def load_command_table(self, _): # only when the extension is installed if should_load_source(source): for target in PASSWORDLESS_TARGET_RESOURCES: + if source == RESOURCE.KubernetesCluster and target == RESOURCE.FabricSql: + continue with self.command_group(f'{source.value} connection create', connection_type, client_factory=cf_linker) as ig: if target in TARGET_RESOURCES_DEPRECATED: diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/config.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/config.py index 4314fdc8d79..ac95db358c0 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/config.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/config.py @@ -4,5 +4,5 @@ # -------------------------------------------------------------------------------------------- -VERSION = '3.1.3' +VERSION = '3.2.0' NAME = 'serviceconnector-passwordless' diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/custom.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/custom.py index 5b74f5b587d..c3bbc8fe85b 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/custom.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/custom.py @@ -28,6 +28,7 @@ def connection_create_ext(cmd, client, spring=None, app=None, deployment='default', # Resource.SpringCloud # Resource.*Postgres, Resource.*Sql* server=None, database=None, + connstr_props=None, **kwargs, ): from azure.cli.command_modules.serviceconnector.custom import connection_create_func @@ -52,6 +53,7 @@ def connection_create_ext(cmd, client, customized_keys=customized_keys, opt_out_list=opt_out_list, app_config_id=app_config_id, + connstr_props=connstr_props, **kwargs) diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/tests/latest/test_serviceconnector-passwordless_scenario.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/tests/latest/test_serviceconnector-passwordless_scenario.py index 40aa7aa69c7..40b83eaca98 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/tests/latest/test_serviceconnector-passwordless_scenario.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/tests/latest/test_serviceconnector-passwordless_scenario.py @@ -355,3 +355,38 @@ def test_local_sql_passwordless(self): # delete connection self.cmd('connection delete --id {} --yes'.format(connection_id)) + + def test_aad_webapp_fabric_sql(self): + self.kwargs.update({ + 'subscription': get_subscription_id(self.cli_ctx), + 'source_resource_group': 'azure-service-connector', + 'site': 'DotNetAppSqlDb20240704', + 'database': 'clitest' + }) + name = 'testfabricconn' + source_id = SOURCE_RESOURCES.get(RESOURCE.WebApp).format(**self.kwargs) + connection_id = source_id + "/providers/Microsoft.ServiceLinker/linkers/" + name + target_id = 'https://api.fabric.microsoft.com/v1/workspaces/13c65326-ecab-43f6-8a05-60927aaa4cec/SqlDatabases/4fdf6efe-23a9-4d74-8c4a-4ecc70c4d323' + server = 'renzo-srv-6ae35870-c362-44b9-8389-ada214a46bb5-51240650dd56.database.windows.net,1433' + database = 'AzureServiceConnectorTestSqlDb-4fdf6efe-23a9-4d74-8c4a-4ecc70c4d323' + + # prepare + self.cmd('webapp identity remove --ids {}'.format(source_id)) + + # create + self.cmd('webapp connection create fabric-sql --connection {} --source-id {} --target-id {} \ + --system-identity --client-type dotnet --opt-out publicnetwork \ + --connstr-props "Server={}" "Database={}" '.format(name, source_id, target_id, server, database) + ) + # clean + self.cmd('webapp connection delete --id {} --yes'.format(connection_id)) + + # recreate and test + self.cmd('webapp connection create fabric-sql --connection {} --source-id {} --target-id {} \ + --system-identity --client-type dotnet --opt-out publicnetwork \ + --connstr-props "Server={}" \ + "Database={}" '.format(name, source_id, target_id, server, database) + ) + + # clean + self.cmd('webapp connection delete --id {} --yes'.format(connection_id)) \ No newline at end of file diff --git a/src/serviceconnector-passwordless/setup.py b/src/serviceconnector-passwordless/setup.py index 62fc66b4f12..027c4211f04 100644 --- a/src/serviceconnector-passwordless/setup.py +++ b/src/serviceconnector-passwordless/setup.py @@ -15,7 +15,7 @@ logger.warn("Wheel is not available, disabling bdist_wheel hook") -VERSION = '3.1.3' +VERSION = '3.2.0' try: from azext_serviceconnector_passwordless.config import VERSION except ImportError: