2929
3030@dataclass
3131class DatabricksJobMetadata (ResourceMeta ):
32+ """Metadata persisted for a Databricks run.
33+
34+ OAuth metadata allows ``get`` and ``delete`` to obtain fresh short-lived
35+ tokens. ``auth_token`` remains populated for PAT jobs and for metadata
36+ written by older connector versions.
37+ """
38+
3239 databricks_instance : str
3340 run_id : str
34- auth_token : Optional [str ] = None # Store auth token for get/delete operations
41+ auth_token : Optional [str ] = None
42+ auth_type : Optional [str ] = None
43+ client_id : Optional [str ] = None
44+ oauth_secret_name : Optional [str ] = None
45+ oidc_token_file : Optional [str ] = None
46+ oidc_service_account : Optional [str ] = None
47+ oidc_audience : Optional [str ] = None
48+ namespace : Optional [str ] = None
3549
3650
3751def _configure_serverless (databricks_job : dict , envs : dict ) -> str :
@@ -254,6 +268,9 @@ class DatabricksConnector(AsyncConnectorBase):
254268
255269 def __init__ (self ):
256270 super ().__init__ (task_type_name = "spark" , metadata_type = DatabricksJobMetadata )
271+ from .databricks_auth import validate_connector_config
272+
273+ validate_connector_config ()
257274
258275 async def create (
259276 self ,
@@ -262,6 +279,8 @@ async def create(
262279 task_execution_metadata : Optional [TaskExecutionMetadata ] = None ,
263280 ** kwargs ,
264281 ) -> DatabricksJobMetadata :
282+ from .databricks_auth import select_auth
283+
265284 data = json .dumps (_get_databricks_job_spec (task_template ))
266285 databricks_instance = task_template .custom .get (
267286 "databricksInstance" , os .getenv (DEFAULT_DATABRICKS_INSTANCE_ENV_KEY )
@@ -272,30 +291,42 @@ async def create(
272291 f"Missing databricks instance. Please set the value through the task config or set the { DEFAULT_DATABRICKS_INSTANCE_ENV_KEY } environment variable in the connector."
273292 )
274293
275- # Get workflow-specific token or fall back to default
276294 namespace = task_execution_metadata .namespace if task_execution_metadata else None
277-
278- # Extract custom secret name from task template (if provided)
279- custom_secret_name = task_template .custom .get ("databricksTokenSecret" )
280-
281- logger .info (f"Creating Databricks job for namespace: { namespace or 'unknown' } " )
282- if custom_secret_name :
283- logger .info (f"Using custom secret name: { custom_secret_name } " )
284-
285- auth_token = get_databricks_token (
286- namespace = namespace , task_template = task_template , secret_name = custom_secret_name
295+ auth = await select_auth (
296+ task_template = task_template ,
297+ workspace_url = databricks_instance ,
298+ namespace = namespace ,
287299 )
300+ logger .info ("Databricks auth resolved: %s" , auth .describe ())
288301 databricks_url = f"https://{ databricks_instance } { DATABRICKS_API_ENDPOINT } /runs/submit"
289302
290303 async with aiohttp .ClientSession () as session :
304+ auth_token = await auth .get_bearer_token (session )
291305 async with session .post (databricks_url , headers = get_header (auth_token = auth_token ), data = data ) as resp :
292306 response = await resp .json ()
293307 if resp .status != http .HTTPStatus .OK :
294308 raise RuntimeError (f"Failed to create databricks job with error: { response } " )
295309
296310 logger .info (f"Successfully created Databricks job with run_id: { response ['run_id' ]} " )
311+ discovered = getattr (auth , "discovered" , None )
312+ persisted_client_id = discovered .client_id if discovered is not None else auth .settings .client_id
313+ persisted_service_account = discovered .service_account if discovered is not None else None
314+ persisted_audience = (
315+ discovered .audience
316+ if discovered is not None
317+ else (auth .settings .oidc_audience if auth .auth_type == "oidc_federation" else None )
318+ )
297319 return DatabricksJobMetadata (
298- databricks_instance = databricks_instance , run_id = str (response ["run_id" ]), auth_token = auth_token
320+ databricks_instance = databricks_instance ,
321+ run_id = str (response ["run_id" ]),
322+ auth_token = auth_token if auth .auth_type == "pat" else None ,
323+ auth_type = auth .auth_type ,
324+ client_id = persisted_client_id ,
325+ oauth_secret_name = auth .settings .oauth_secret_name ,
326+ oidc_token_file = (auth .settings .oidc_token_file if auth .auth_type == "oidc_federation" else None ),
327+ oidc_service_account = persisted_service_account ,
328+ oidc_audience = persisted_audience ,
329+ namespace = namespace ,
299330 )
300331
301332 async def get (self , resource_meta : DatabricksJobMetadata , ** kwargs ) -> Resource :
@@ -304,14 +335,14 @@ async def get(self, resource_meta: DatabricksJobMetadata, **kwargs) -> Resource:
304335 f"https://{ databricks_instance } { DATABRICKS_API_ENDPOINT } /runs/get?run_id={ resource_meta .run_id } "
305336 )
306337
307- # Use the stored auth token if available, otherwise fall back to default
308- headers = get_header (auth_token = resource_meta .auth_token )
309-
310338 async with aiohttp .ClientSession () as session :
311- async with session .get (databricks_url , headers = headers ) as resp :
312- if resp .status != http .HTTPStatus .OK :
313- raise RuntimeError (f"Failed to get databricks job { resource_meta .run_id } with error: { resp .reason } " )
314- response = await resp .json ()
339+ response = await self ._request_with_auth (
340+ session = session ,
341+ method = "GET" ,
342+ url = databricks_url ,
343+ resource_meta = resource_meta ,
344+ action_label = f"get databricks job { resource_meta .run_id } " ,
345+ )
315346
316347 cur_phase = TaskExecution .UNDEFINED
317348 message = ""
@@ -339,16 +370,69 @@ async def delete(self, resource_meta: DatabricksJobMetadata, **kwargs):
339370 databricks_url = f"https://{ resource_meta .databricks_instance } { DATABRICKS_API_ENDPOINT } /runs/cancel"
340371 data = json .dumps ({"run_id" : resource_meta .run_id })
341372
342- # Use the stored auth token if available, otherwise fall back to default
343- headers = get_header (auth_token = resource_meta .auth_token )
344-
345373 async with aiohttp .ClientSession () as session :
346- async with session .post (databricks_url , headers = headers , data = data ) as resp :
347- if resp .status != http .HTTPStatus .OK :
348- raise RuntimeError (
349- f"Failed to cancel databricks job { resource_meta .run_id } with error: { resp .reason } "
350- )
351- await resp .json ()
374+ await self ._request_with_auth (
375+ session = session ,
376+ method = "POST" ,
377+ url = databricks_url ,
378+ resource_meta = resource_meta ,
379+ data = data ,
380+ action_label = f"cancel databricks job { resource_meta .run_id } " ,
381+ )
382+
383+ async def _request_with_auth (
384+ self ,
385+ session : "aiohttp.ClientSession" , # type: ignore[name-defined]
386+ method : str ,
387+ url : str ,
388+ resource_meta : DatabricksJobMetadata ,
389+ action_label : str ,
390+ data : Optional [str ] = None ,
391+ ) -> dict :
392+ """Call the Jobs API and retry once after refreshing OAuth on 401."""
393+ from .databricks_auth import DatabricksAuthError , build_auth
394+
395+ auth = None
396+ if resource_meta .auth_type in {"oauth_m2m" , "oidc_federation" }:
397+ auth = build_auth (
398+ workspace_url = resource_meta .databricks_instance ,
399+ auth_type = resource_meta .auth_type ,
400+ namespace = resource_meta .namespace ,
401+ client_id = resource_meta .client_id ,
402+ oauth_secret_name = resource_meta .oauth_secret_name ,
403+ oidc_token_file = resource_meta .oidc_token_file ,
404+ oidc_audience = resource_meta .oidc_audience ,
405+ oidc_service_account = resource_meta .oidc_service_account ,
406+ )
407+
408+ token = resource_meta .auth_token
409+ if auth is not None :
410+ try :
411+ token = await auth .get_bearer_token (session )
412+ except DatabricksAuthError as error :
413+ raise RuntimeError (f"Failed to { action_label } : could not obtain Databricks auth: { error } " ) from error
414+
415+ def _request (bearer : Optional [str ]):
416+ headers = get_header (auth_token = bearer )
417+ if method .upper () == "GET" :
418+ return session .get (url , headers = headers )
419+ return session .post (url , headers = headers , data = data )
420+
421+ async with _request (token ) as response :
422+ if response .status == http .HTTPStatus .UNAUTHORIZED and auth is not None :
423+ await auth .invalidate_cache ()
424+ try :
425+ refreshed_token = await auth .get_bearer_token (session )
426+ except DatabricksAuthError as error :
427+ raise RuntimeError (f"Failed to { action_label } : auth refresh failed after 401: { error } " ) from error
428+ async with _request (refreshed_token ) as retry_response :
429+ if retry_response .status != http .HTTPStatus .OK :
430+ raise RuntimeError (f"Failed to { action_label } with error: { retry_response .reason } " )
431+ return await retry_response .json ()
432+
433+ if response .status != http .HTTPStatus .OK :
434+ raise RuntimeError (f"Failed to { action_label } with error: { response .reason } " )
435+ return await response .json ()
352436
353437
354438class DatabricksConnectorV2 (DatabricksConnector ):
@@ -364,6 +448,32 @@ def __init__(self):
364448 super (DatabricksConnector , self ).__init__ (task_type_name = "databricks" , metadata_type = DatabricksJobMetadata )
365449
366450
451+ def list_serviceaccounts_in_k8s (namespace : str , label_selector : Optional [str ] = None ) -> list :
452+ """List labelled ServiceAccounts in a workflow namespace."""
453+ try :
454+ from kubernetes import client , config
455+
456+ try :
457+ config .load_incluster_config ()
458+ except config .ConfigException :
459+ config .load_kube_config ()
460+
461+ arguments = {"namespace" : namespace }
462+ if label_selector :
463+ arguments ["label_selector" ] = label_selector
464+ response = client .CoreV1Api ().list_namespaced_service_account (** arguments )
465+ return list (response .items or [])
466+ except ImportError :
467+ logger .warning ("Kubernetes Python client is unavailable; skipping namespace " "ServiceAccount discovery" )
468+ except Exception as error :
469+ logger .warning (
470+ "Unable to discover ServiceAccounts in namespace '%s': %s" ,
471+ namespace ,
472+ error ,
473+ )
474+ return []
475+
476+
367477def get_secret_from_k8s (secret_name : str , secret_key : str , namespace : str ) -> Optional [str ]:
368478 """Read a secret from Kubernetes using the Kubernetes Python client.
369479
0 commit comments