11import logging
2- from dataclasses import dataclass
2+ from dataclasses import dataclass , field
33
44from databricks .labs .blueprint .installation import Installation
55from databricks .labs .blueprint .tui import Prompts
66from databricks .sdk import WorkspaceClient
7+ from databricks .sdk .errors import BadRequest
78from databricks .sdk .errors .platform import InvalidParameterValue
89from databricks .sdk .service .catalog import (
10+ AzureManagedIdentityRequest ,
911 AzureServicePrincipal ,
1012 Privilege ,
1113 StorageCredentialInfo ,
@@ -33,19 +35,42 @@ class ServicePrincipalMigrationInfo:
3335class StorageCredentialValidationResult :
3436 name : str
3537 application_id : str
36- read_only : bool
38+ read_only : bool | None
3739 validated_on : str
38- directory_id : str | None = None
39- failures : list [str ] | None = None
40+ directory_id : str | None = None # str when storage credential created for AzureServicePrincipal
41+ failures : list [str ] = field ( default_factory = list )
4042
4143 @classmethod
42- def from_validation (cls , permission_mapping : StoragePermissionMapping , failures : list [str ] | None ):
44+ def _get_application_and_directory_id (
45+ cls , storage_credential_info : StorageCredentialInfo
46+ ) -> tuple [str , str | None ]:
47+ if storage_credential_info .azure_service_principal is not None :
48+ application_id = storage_credential_info .azure_service_principal .application_id
49+ directory_id = storage_credential_info .azure_service_principal .directory_id
50+ return application_id , directory_id
51+
52+ if storage_credential_info .azure_managed_identity is not None :
53+ if storage_credential_info .azure_managed_identity .managed_identity_id is not None :
54+ return storage_credential_info .azure_managed_identity .managed_identity_id , None
55+ return storage_credential_info .azure_managed_identity .access_connector_id , None
56+
57+ raise KeyError ("Storage credential info is missing an application id." )
58+
59+ @classmethod
60+ def from_storage_credential_info (
61+ cls ,
62+ storage_credential_info : StorageCredentialInfo ,
63+ validated_on : str ,
64+ failures : list [str ],
65+ ):
66+ assert storage_credential_info .name is not None
67+ application_id , directory_id = cls ._get_application_and_directory_id (storage_credential_info )
4368 return cls (
44- permission_mapping . principal ,
45- permission_mapping . client_id ,
46- permission_mapping . privilege == Privilege . READ_FILES . value ,
47- permission_mapping . prefix ,
48- permission_mapping . directory_id ,
69+ storage_credential_info . name ,
70+ application_id ,
71+ storage_credential_info . read_only ,
72+ validated_on ,
73+ directory_id ,
4974 failures ,
5075 )
5176
@@ -104,30 +129,31 @@ def create_with_client_secret(self, spn: ServicePrincipalMigrationInfo) -> Stora
104129 read_only = spn .permission_mapping .privilege == Privilege .READ_FILES .value ,
105130 )
106131
107- def validate (self , permission_mapping : StoragePermissionMapping ) -> StorageCredentialValidationResult :
132+ def validate (self , storage_credential_info : StorageCredentialInfo , url : str ) -> StorageCredentialValidationResult :
108133 try :
109134 validation = self ._ws .storage_credentials .validate (
110- storage_credential_name = permission_mapping . principal ,
111- url = permission_mapping . prefix ,
112- read_only = permission_mapping . privilege == Privilege . READ_FILES . value ,
135+ storage_credential_name = storage_credential_info . name ,
136+ url = url ,
137+ read_only = storage_credential_info . read_only ,
113138 )
114139 except InvalidParameterValue :
115140 logger .warning (
116141 "There is an existing external location overlaps with the prefix that is mapped to "
117142 "the service principal and used for validating the migrated storage credential. "
118143 "Skip the validation"
119144 )
120- return StorageCredentialValidationResult .from_validation (
121- permission_mapping ,
145+ return StorageCredentialValidationResult .from_storage_credential_info (
146+ storage_credential_info ,
147+ url ,
122148 [
123149 "The validation is skipped because an existing external location overlaps "
124150 "with the location used for validation."
125151 ],
126152 )
127153
128154 if not validation .results :
129- return StorageCredentialValidationResult .from_validation (
130- permission_mapping , ["Validation returned no results." ]
155+ return StorageCredentialValidationResult .from_storage_credential_info (
156+ storage_credential_info , url , ["Validation returned no results." ]
131157 )
132158
133159 failures = []
@@ -136,7 +162,8 @@ def validate(self, permission_mapping: StoragePermissionMapping) -> StorageCrede
136162 continue
137163 if result .result == ValidationResultResult .FAIL :
138164 failures .append (f"{ result .operation .value } validation failed with message: { result .message } " )
139- return StorageCredentialValidationResult .from_validation (permission_mapping , None if not failures else failures )
165+
166+ return StorageCredentialValidationResult .from_storage_credential_info (storage_credential_info , url , failures )
140167
141168
142169class ServicePrincipalMigration (SecretsMixin ):
@@ -238,53 +265,69 @@ def _migrate_service_principals(
238265 f"'{ spn .permission_mapping .prefix } ' with non-Allow network configuration"
239266 )
240267
241- self ._storage_credential_manager .create_with_client_secret (spn )
242- execution_result .append (self ._storage_credential_manager .validate (spn .permission_mapping ))
243-
268+ storage_credential_info = self ._storage_credential_manager .create_with_client_secret (spn )
269+ validation_results = self ._storage_credential_manager .validate (
270+ storage_credential_info ,
271+ spn .permission_mapping .prefix ,
272+ )
273+ execution_result .append (validation_results )
244274 return execution_result
245275
246- def _create_access_connectors_for_storage_accounts (self ) -> list [StorageCredentialValidationResult ]:
247- self ._resource_permissions .create_access_connectors_for_storage_accounts ()
248- return []
276+ def _create_storage_credentials_for_storage_accounts (self ) -> list [StorageCredentialValidationResult ]:
277+ access_connectors = self ._resource_permissions .create_access_connectors_for_storage_accounts ()
278+
279+ execution_results = []
280+ for access_connector , url in access_connectors :
281+ storage_credential_info = self ._ws .storage_credentials .create (
282+ access_connector .name ,
283+ azure_managed_identity = AzureManagedIdentityRequest (str (access_connector .id )),
284+ comment = "Created by UCX" ,
285+ read_only = False , # Access connectors get "STORAGE_BLOB_DATA_CONTRIBUTOR" permissions
286+ )
287+ try :
288+ validation_results = self ._storage_credential_manager .validate (storage_credential_info , url )
289+ except BadRequest :
290+ logger .warning (f"Could not validate storage credential { storage_credential_info .name } for url { url } " )
291+ else :
292+ execution_results .append (validation_results )
293+
294+ return execution_results
249295
250296 def run (self , prompts : Prompts , include_names : set [str ] | None = None ) -> list [StorageCredentialValidationResult ]:
251297 plan_confirmed = prompts .confirm (
252- "Above Azure Service Principals will be migrated to UC storage credentials, please review and confirm."
298+ "[RECOMMENDED] Please confirm to create an access connector with a managed identity for each storage "
299+ "account."
253300 )
254- sp_results = []
301+ ac_results = []
255302 if plan_confirmed :
256- sp_migration_infos = self ._generate_migration_list (include_names )
257- plan_confirmed = True
258- if any (spn .permission_mapping .default_network_action != "Allow" for spn in sp_migration_infos ):
259- plan_confirmed = prompts .confirm (
260- "At least one Azure Service Principal accesses a storage account with non-Allow default network "
261- "configuration, which might cause connectivity issues. We recommend using Databricks Access "
262- "Connectors instead (next prompt). Would you like to continue with migrating the service "
263- "principals?"
264- )
265- if plan_confirmed :
266- sp_results = self ._migrate_service_principals (sp_migration_infos )
303+ ac_results = self ._create_storage_credentials_for_storage_accounts ()
267304
305+ sp_migration_infos = self ._generate_migration_list (include_names )
306+ if any (spn .permission_mapping .default_network_action != "Allow" for spn in sp_migration_infos ):
307+ logger .warning (
308+ "At least one Azure Service Principal accesses a storage account with non-Allow default network "
309+ "configuration, which might cause connectivity issues. We recommend using Databricks Access "
310+ "Connectors instead"
311+ )
268312 plan_confirmed = prompts .confirm (
269- "[RECOMMENDED] Please confirm to create an access connector with a managed identity for each storage "
270- "account."
313+ "Above Azure Service Principals will be migrated to UC storage credentials, please review and confirm."
271314 )
272- ac_results = []
315+ sp_results = []
273316 if plan_confirmed :
274- ac_results = self ._create_access_connectors_for_storage_accounts ( )
317+ sp_results = self ._migrate_service_principals ( sp_migration_infos )
275318
276- execution_results = sp_results + ac_results
319+ execution_results = ac_results + sp_results
277320 if execution_results :
278321 results_file = self .save (execution_results )
279322 logger .info (
280323 "Completed migration from Azure Service Principal to UC Storage credentials "
281- "and creation of Databricks Access Connectors for storage accounts "
324+ "and creation of UC Storage credentials for storage access with Databricks Access Connectors. "
282325 f"Please check { results_file } for validation results"
283326 )
284327 else :
285328 logger .info (
286- "No Azure Service Principal migrated to UC Storage credentials "
287- "nor Databricks Access Connectors created for storage accounts "
329+ "No UC Storage credentials created during Azure Service Principal migration "
330+ "nor for storage access with Databricks Access Connectors. "
288331 )
289332
290333 return execution_results
0 commit comments