Skip to content

Commit e94cbb2

Browse files
committed
feat(spark): add Databricks OAuth M2M authentication
Allow connectors and individual tasks to opt into short-lived service-principal tokens while keeping PAT as the unchanged default authentication mode. Signed-off-by: Rohit Sharma <rohitrsh@gmail.com>
1 parent ecfd142 commit e94cbb2

7 files changed

Lines changed: 741 additions & 35 deletions

File tree

plugins/flytekit-spark/README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,43 @@ pip install flytekitplugins-spark
1111
To configure Spark in the Flyte deployment's backend, follow [Step 1](https://docs.flyte.org/en/latest/deployment/plugins/k8s/index.html#deployment-plugin-setup-k8s), [2](https://docs.flyte.org/en/latest/flytesnacks/examples/k8s_spark_plugin/index.html).
1212

1313
All [examples](https://docs.flyte.org/en/latest/flytesnacks/examples/k8s_spark_plugin/index.html) showcasing execution of Spark jobs using the plugin can be found in the documentation.
14+
15+
## Databricks authentication
16+
17+
The Databricks connector uses PAT authentication by default, preserving the
18+
existing `databricks-token` namespace Secret and
19+
`FLYTE_DATABRICKS_ACCESS_TOKEN` fallback.
20+
21+
OAuth machine-to-machine (M2M) authentication can be enabled on the connector:
22+
23+
```yaml
24+
env:
25+
- name: FLYTE_DATABRICKS_AUTH_TYPE
26+
value: oauth_m2m
27+
- name: DATABRICKS_CLIENT_ID
28+
value: "<service-principal-client-id>"
29+
- name: DATABRICKS_CLIENT_SECRET
30+
valueFrom:
31+
secretKeyRef:
32+
name: databricks-connector-oauth
33+
key: client_secret
34+
```
35+
36+
For per-namespace identities, create a `databricks-oauth` Secret in each
37+
workflow namespace:
38+
39+
```yaml
40+
apiVersion: v1
41+
kind: Secret
42+
metadata:
43+
name: databricks-oauth
44+
namespace: "<workflow-namespace>"
45+
type: Opaque
46+
stringData:
47+
client_id: "<service-principal-client-id>"
48+
client_secret: "<service-principal-client-secret>"
49+
```
50+
51+
The namespace Secret takes precedence over connector-level credentials.
52+
`get` and `delete` operations cache short-lived OAuth tokens and retry once
53+
with a refreshed token when the Databricks API returns HTTP 401.

plugins/flytekit-spark/flytekitplugins/spark/connector.py

Lines changed: 93 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,20 @@
2929

3030
@dataclass
3131
class 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+
namespace: Optional[str] = None
3546

3647

3748
def _configure_serverless(databricks_job: dict, envs: dict) -> str:
@@ -254,6 +265,9 @@ class DatabricksConnector(AsyncConnectorBase):
254265

255266
def __init__(self):
256267
super().__init__(task_type_name="spark", metadata_type=DatabricksJobMetadata)
268+
from .databricks_auth import validate_connector_config
269+
270+
validate_connector_config()
257271

258272
async def create(
259273
self,
@@ -275,7 +289,11 @@ async def create(
275289
)
276290

277291
namespace = task_execution_metadata.namespace if task_execution_metadata else None
278-
auth = await select_auth(task_template=task_template, namespace=namespace)
292+
auth = await select_auth(
293+
task_template=task_template,
294+
workspace_url=databricks_instance,
295+
namespace=namespace,
296+
)
279297
logger.info("Databricks auth resolved: %s", auth.describe())
280298
databricks_url = f"https://{databricks_instance}{DATABRICKS_API_ENDPOINT}/runs/submit"
281299

@@ -288,7 +306,13 @@ async def create(
288306

289307
logger.info(f"Successfully created Databricks job with run_id: {response['run_id']}")
290308
return DatabricksJobMetadata(
291-
databricks_instance=databricks_instance, run_id=str(response["run_id"]), auth_token=auth_token
309+
databricks_instance=databricks_instance,
310+
run_id=str(response["run_id"]),
311+
auth_token=auth_token if auth.auth_type == "pat" else None,
312+
auth_type=auth.auth_type,
313+
client_id=auth.settings.client_id,
314+
oauth_secret_name=auth.settings.oauth_secret_name,
315+
namespace=namespace,
292316
)
293317

294318
async def get(self, resource_meta: DatabricksJobMetadata, **kwargs) -> Resource:
@@ -297,14 +321,14 @@ async def get(self, resource_meta: DatabricksJobMetadata, **kwargs) -> Resource:
297321
f"https://{databricks_instance}{DATABRICKS_API_ENDPOINT}/runs/get?run_id={resource_meta.run_id}"
298322
)
299323

300-
# Use the stored auth token if available, otherwise fall back to default
301-
headers = get_header(auth_token=resource_meta.auth_token)
302-
303324
async with aiohttp.ClientSession() as session:
304-
async with session.get(databricks_url, headers=headers) as resp:
305-
if resp.status != http.HTTPStatus.OK:
306-
raise RuntimeError(f"Failed to get databricks job {resource_meta.run_id} with error: {resp.reason}")
307-
response = await resp.json()
325+
response = await self._request_with_auth(
326+
session=session,
327+
method="GET",
328+
url=databricks_url,
329+
resource_meta=resource_meta,
330+
action_label=f"get databricks job {resource_meta.run_id}",
331+
)
308332

309333
cur_phase = TaskExecution.UNDEFINED
310334
message = ""
@@ -332,16 +356,66 @@ async def delete(self, resource_meta: DatabricksJobMetadata, **kwargs):
332356
databricks_url = f"https://{resource_meta.databricks_instance}{DATABRICKS_API_ENDPOINT}/runs/cancel"
333357
data = json.dumps({"run_id": resource_meta.run_id})
334358

335-
# Use the stored auth token if available, otherwise fall back to default
336-
headers = get_header(auth_token=resource_meta.auth_token)
337-
338359
async with aiohttp.ClientSession() as session:
339-
async with session.post(databricks_url, headers=headers, data=data) as resp:
340-
if resp.status != http.HTTPStatus.OK:
341-
raise RuntimeError(
342-
f"Failed to cancel databricks job {resource_meta.run_id} with error: {resp.reason}"
343-
)
344-
await resp.json()
360+
await self._request_with_auth(
361+
session=session,
362+
method="POST",
363+
url=databricks_url,
364+
resource_meta=resource_meta,
365+
data=data,
366+
action_label=f"cancel databricks job {resource_meta.run_id}",
367+
)
368+
369+
async def _request_with_auth(
370+
self,
371+
session: "aiohttp.ClientSession", # type: ignore[name-defined]
372+
method: str,
373+
url: str,
374+
resource_meta: DatabricksJobMetadata,
375+
action_label: str,
376+
data: Optional[str] = None,
377+
) -> dict:
378+
"""Call the Jobs API and retry once after refreshing OAuth on 401."""
379+
from .databricks_auth import DatabricksAuthError, build_auth
380+
381+
auth = None
382+
if resource_meta.auth_type == "oauth_m2m":
383+
auth = build_auth(
384+
workspace_url=resource_meta.databricks_instance,
385+
auth_type=resource_meta.auth_type,
386+
namespace=resource_meta.namespace,
387+
client_id=resource_meta.client_id,
388+
oauth_secret_name=resource_meta.oauth_secret_name,
389+
)
390+
391+
token = resource_meta.auth_token
392+
if auth is not None:
393+
try:
394+
token = await auth.get_bearer_token(session)
395+
except DatabricksAuthError as error:
396+
raise RuntimeError(f"Failed to {action_label}: could not obtain Databricks auth: {error}") from error
397+
398+
def _request(bearer: Optional[str]):
399+
headers = get_header(auth_token=bearer)
400+
if method.upper() == "GET":
401+
return session.get(url, headers=headers)
402+
return session.post(url, headers=headers, data=data)
403+
404+
async with _request(token) as response:
405+
if response.status == http.HTTPStatus.UNAUTHORIZED and auth is not None:
406+
await auth.invalidate_cache()
407+
try:
408+
refreshed_token = await auth.get_bearer_token(session)
409+
except DatabricksAuthError as error:
410+
raise RuntimeError(f"Failed to {action_label}: auth refresh failed after 401: {error}") from error
411+
async with _request(refreshed_token) as retry_response:
412+
if retry_response.status != http.HTTPStatus.OK:
413+
raise RuntimeError(f"Failed to {action_label} with error: {retry_response.reason}")
414+
return await retry_response.json()
415+
416+
if response.status != http.HTTPStatus.OK:
417+
raise RuntimeError(f"Failed to {action_label} with error: {response.reason}")
418+
return await response.json()
345419

346420

347421
class DatabricksConnectorV2(DatabricksConnector):

0 commit comments

Comments
 (0)