Skip to content

Commit d69b3fb

Browse files
authored
feat(spark): unify Databricks PAT, OAuth M2M, connector OIDC, and namespace OIDC auth (#3455)
Signed-off-by: Rohit Sharma <rohitrsh@gmail.com>
1 parent aae1335 commit d69b3fb

7 files changed

Lines changed: 1844 additions & 29 deletions

File tree

plugins/flytekit-spark/README.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,108 @@ 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.
54+
55+
### OIDC workload identity federation
56+
57+
The connector can exchange its own projected workload JWT for a short-lived
58+
Databricks token without storing a client secret:
59+
60+
```yaml
61+
env:
62+
- name: FLYTE_DATABRICKS_AUTH_TYPE
63+
value: oidc_federation
64+
- name: DATABRICKS_CLIENT_ID
65+
value: "<service-principal-client-id>"
66+
- name: FLYTE_DATABRICKS_OIDC_TOKEN_FILE
67+
value: /var/run/secrets/databricks/token
68+
```
69+
70+
The token file is resolved in this order:
71+
72+
1. `databricks_oidc_token_file` task override or
73+
`FLYTE_DATABRICKS_OIDC_TOKEN_FILE`
74+
2. `AWS_WEB_IDENTITY_TOKEN_FILE`
75+
3. `/var/run/secrets/databricks/token`
76+
77+
The connector deployment is responsible for projecting a JWT at one of these
78+
paths and configuring a matching federation policy for the Databricks service
79+
principal. PAT remains the default unless `oidc_federation` is selected
80+
explicitly.
81+
82+
#### Per-namespace ServiceAccount identity
83+
84+
When OIDC federation is selected, the connector first looks for one
85+
ServiceAccount in the workflow namespace with this configuration:
86+
87+
```yaml
88+
apiVersion: v1
89+
kind: ServiceAccount
90+
metadata:
91+
name: databricks-workload
92+
namespace: "<workflow-namespace>"
93+
labels:
94+
flyte.org/databricks-enabled: "true"
95+
annotations:
96+
flyte.org/databricks-client-id: "<service-principal-client-id>"
97+
flyte.org/databricks-audience: "databricks"
98+
```
99+
100+
If found, the connector creates a short-lived JWT for that ServiceAccount
101+
through the Kubernetes TokenRequest API and exchanges it for a Databricks
102+
token. If no matching ServiceAccount exists, connector-identity OIDC remains
103+
the fallback.
104+
105+
The connector ServiceAccount needs these additional Kubernetes permissions:
106+
107+
```yaml
108+
rules:
109+
- apiGroups: [""]
110+
resources: ["serviceaccounts"]
111+
verbs: ["get", "list"]
112+
- apiGroups: [""]
113+
resources: ["serviceaccounts/token"]
114+
verbs: ["create"]
115+
```
116+
117+
Configure exactly one Databricks-enabled ServiceAccount per workflow
118+
namespace. Multiple matching ServiceAccounts are treated as an error.

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

Lines changed: 139 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,23 @@
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+
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

3751
def _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

354438
class 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+
367477
def 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

Comments
 (0)