pipeloom-databricks 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pipeloom_databricks/__init__.py +18 -0
- pipeloom_databricks/api.py +45 -0
- pipeloom_databricks/bundle.py +113 -0
- pipeloom_databricks/cli.py +561 -0
- pipeloom_databricks/client/__init__.py +121 -0
- pipeloom_databricks/client/connect.py +84 -0
- pipeloom_databricks/client/sdk.py +275 -0
- pipeloom_databricks/deploy.py +188 -0
- pipeloom_databricks/finalize_states.py +72 -0
- pipeloom_databricks/grouping.py +158 -0
- pipeloom_databricks/jobs_model.py +116 -0
- pipeloom_databricks/keys.py +45 -0
- pipeloom_databricks/options.py +66 -0
- pipeloom_databricks/py.typed +0 -0
- pipeloom_databricks/render.py +396 -0
- pipeloom_databricks/repair.py +66 -0
- pipeloom_databricks/run.py +357 -0
- pipeloom_databricks/secrets.py +50 -0
- pipeloom_databricks/serialize.py +30 -0
- pipeloom_databricks/spec.py +75 -0
- pipeloom_databricks/task.py +422 -0
- pipeloom_databricks/task_values.py +69 -0
- pipeloom_databricks/testing/__init__.py +1 -0
- pipeloom_databricks/testing/fake.py +293 -0
- pipeloom_databricks/testing/jobsim.py +179 -0
- pipeloom_databricks/unity.py +23 -0
- pipeloom_databricks/upload.py +217 -0
- pipeloom_databricks-0.5.0.dist-info/METADATA +114 -0
- pipeloom_databricks-0.5.0.dist-info/RECORD +33 -0
- pipeloom_databricks-0.5.0.dist-info/WHEEL +4 -0
- pipeloom_databricks-0.5.0.dist-info/entry_points.txt +14 -0
- pipeloom_databricks-0.5.0.dist-info/licenses/LICENSE +202 -0
- pipeloom_databricks-0.5.0.dist-info/licenses/NOTICE +2 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Choose the workspace connection (ADR-0036). No profile is ever picked automatically.
|
|
2
|
+
|
|
3
|
+
Order: an explicit profile (`--profile`), a profile the user set in `DATABRICKS_CONFIG_PROFILE`,
|
|
4
|
+
host and credentials in `DATABRICKS_HOST` and friends, runtime auth when
|
|
5
|
+
`DATABRICKS_RUNTIME_VERSION` is set, else `ConnectionNotConfigured` (PLD010). Without a profile
|
|
6
|
+
the SDK gets a `config_file` that does not exist, so it cannot fall back to a `DEFAULT` profile of
|
|
7
|
+
`~/.databrickscfg`. The SDK loads lazily, only here and in `sdk.py`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib
|
|
13
|
+
import tempfile
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Final
|
|
17
|
+
|
|
18
|
+
from pipeloom.secrets.redaction import Redactor
|
|
19
|
+
from pipeloom_databricks.client import ConnectionNotConfigured, DatabricksClient
|
|
20
|
+
|
|
21
|
+
_CREDENTIALS: Final = {
|
|
22
|
+
"DATABRICKS_TOKEN": "token",
|
|
23
|
+
"DATABRICKS_CLIENT_ID": "client_id",
|
|
24
|
+
"DATABRICKS_CLIENT_SECRET": "client_secret",
|
|
25
|
+
}
|
|
26
|
+
_SECRET_ATTRS: Final = ("token", "client_secret", "password")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _factory(spec: str) -> DatabricksClient:
|
|
30
|
+
"""`module:attr` called with no arguments (a test double; `--client-factory`)."""
|
|
31
|
+
module, sep, attr = spec.partition(":")
|
|
32
|
+
if not (module and sep and attr):
|
|
33
|
+
raise ConnectionNotConfigured(f"--client-factory '{spec}' is not MODULE:ATTR")
|
|
34
|
+
try:
|
|
35
|
+
make = getattr(importlib.import_module(module), attr)
|
|
36
|
+
except (ImportError, AttributeError) as err:
|
|
37
|
+
raise ConnectionNotConfigured(
|
|
38
|
+
f"--client-factory '{spec}' cannot be loaded: {err}"
|
|
39
|
+
) from None
|
|
40
|
+
client: DatabricksClient = make()
|
|
41
|
+
return client
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def connect(
|
|
45
|
+
*, profile: str | None, env: Mapping[str, str], factory: str | None = None
|
|
46
|
+
) -> DatabricksClient:
|
|
47
|
+
if factory:
|
|
48
|
+
return _factory(factory)
|
|
49
|
+
profile = profile or env.get("DATABRICKS_CONFIG_PROFILE") or None
|
|
50
|
+
host = env.get("DATABRICKS_HOST")
|
|
51
|
+
if profile:
|
|
52
|
+
kwargs: dict[str, Any] = {"profile": profile}
|
|
53
|
+
elif host:
|
|
54
|
+
kwargs = {"host": host}
|
|
55
|
+
kwargs.update({attr: env[name] for name, attr in _CREDENTIALS.items() if env.get(name)})
|
|
56
|
+
elif env.get("DATABRICKS_RUNTIME_VERSION"):
|
|
57
|
+
kwargs = {"auth_type": "runtime"}
|
|
58
|
+
else:
|
|
59
|
+
raise ConnectionNotConfigured(
|
|
60
|
+
"no Databricks connection: pass --profile, set DATABRICKS_CONFIG_PROFILE, set "
|
|
61
|
+
"DATABRICKS_HOST with credentials, or run on a Databricks runtime"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
from databricks.sdk import WorkspaceClient
|
|
65
|
+
from databricks.sdk.core import Config
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
if profile:
|
|
69
|
+
config = Config(**kwargs)
|
|
70
|
+
else:
|
|
71
|
+
with tempfile.TemporaryDirectory(prefix="pipeloom-") as folder:
|
|
72
|
+
config = Config(config_file=str(Path(folder) / "none.cfg"), **kwargs)
|
|
73
|
+
workspace = WorkspaceClient(config=config)
|
|
74
|
+
except ValueError as err:
|
|
75
|
+
raise ConnectionNotConfigured(f"the Databricks connection is invalid: {err}") from None
|
|
76
|
+
|
|
77
|
+
from pipeloom_databricks.client.sdk import SdkClient
|
|
78
|
+
|
|
79
|
+
redactor = Redactor()
|
|
80
|
+
for attr in _SECRET_ATTRS:
|
|
81
|
+
value = getattr(config, attr, None)
|
|
82
|
+
if isinstance(value, str):
|
|
83
|
+
redactor.register(value)
|
|
84
|
+
return SdkClient(workspace.api_client, str(config.host or "").rstrip("/"), redactor)
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""`SdkClient`: the REST calls of the plugin, every one through `WorkspaceClient.api_client.do`.
|
|
2
|
+
|
|
3
|
+
Going through `do` (not the typed SDK methods) keeps the JSON exactly as rendered, so a field the
|
|
4
|
+
installed SDK does not know yet, such as `environment_version`, is never dropped. This is one of
|
|
5
|
+
the three modules that import `databricks.sdk`; `connect` imports this one lazily.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
12
|
+
from typing import Any, Final, Protocol
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
|
|
15
|
+
from databricks.sdk.errors.base import DatabricksError
|
|
16
|
+
from pydantic import JsonValue
|
|
17
|
+
|
|
18
|
+
from pipeloom.secrets.redaction import Redactor
|
|
19
|
+
from pipeloom_databricks.client import ApiError, DirEntry, JobSummary, RunInfo, TaskRunInfo
|
|
20
|
+
|
|
21
|
+
JOBS: Final = "/api/2.2/jobs"
|
|
22
|
+
WORKSPACE: Final = "/api/2.0/workspace"
|
|
23
|
+
IMPORT_FORMAT: Final = "AUTO"
|
|
24
|
+
"""The workspace import format; ADR-0036 explains why."""
|
|
25
|
+
_RESULT: Final = {
|
|
26
|
+
"SUCCESS": "SUCCESS",
|
|
27
|
+
"SUCCESS_WITH_FAILURES": "SUCCESS_WITH_FAILURES",
|
|
28
|
+
"USER_CANCELED": "CANCELED",
|
|
29
|
+
"CANCELED": "CANCELED",
|
|
30
|
+
"UPSTREAM_FAILED": "UPSTREAM_FAILED",
|
|
31
|
+
"UPSTREAM_CANCELED": "UPSTREAM_CANCELED",
|
|
32
|
+
"MAX_CONCURRENT_RUNS_EXCEEDED": "MAXIMUM_CONCURRENT_RUNS_REACHED",
|
|
33
|
+
"EXCLUDED": "EXCLUDED",
|
|
34
|
+
"DISABLED": "DISABLED",
|
|
35
|
+
}
|
|
36
|
+
"""`termination_details.code` values that name a result state; any other code is a failure."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _Api(Protocol):
|
|
40
|
+
def do(
|
|
41
|
+
self,
|
|
42
|
+
method: str,
|
|
43
|
+
path: str,
|
|
44
|
+
*,
|
|
45
|
+
query: dict[str, Any] | None = ...,
|
|
46
|
+
body: dict[str, Any] | None = ...,
|
|
47
|
+
raw: bool = ...,
|
|
48
|
+
) -> Any: ...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _status(err: DatabricksError) -> int:
|
|
52
|
+
"""The HTTP status of an SDK error: its own `status_code` when it has one, else the status
|
|
53
|
+
the SDK maps to the nearest class in its MRO (read from `STATUS_CODE_MAPPING`, so subclasses
|
|
54
|
+
such as `Aborted` or `DeadlineExceeded` resolve too), else 0."""
|
|
55
|
+
own = getattr(err, "status_code", None)
|
|
56
|
+
if isinstance(own, int) and not isinstance(own, bool):
|
|
57
|
+
return own
|
|
58
|
+
from databricks.sdk.errors import platform
|
|
59
|
+
|
|
60
|
+
by_class = {cls: code for code, cls in getattr(platform, "STATUS_CODE_MAPPING", {}).items()}
|
|
61
|
+
for cls in type(err).__mro__:
|
|
62
|
+
if cls in by_class:
|
|
63
|
+
return int(by_class[cls])
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _states(item: Mapping[str, Any]) -> tuple[str, str | None, str | None, str | None]:
|
|
68
|
+
"""life cycle state, result state, termination code and message of a run or a task run.
|
|
69
|
+
|
|
70
|
+
The `status` object is read when present, the legacy `state` object otherwise.
|
|
71
|
+
"""
|
|
72
|
+
state: Mapping[str, Any] = item.get("state") or {}
|
|
73
|
+
status: Mapping[str, Any] = item.get("status") or {}
|
|
74
|
+
details: Mapping[str, Any] = status.get("termination_details") or {}
|
|
75
|
+
life = (status.get("state") if status else None) or state.get("life_cycle_state") or "UNKNOWN"
|
|
76
|
+
code = details.get("code")
|
|
77
|
+
result = state.get("result_state")
|
|
78
|
+
if result is None and life == "TERMINATED" and code:
|
|
79
|
+
result = _RESULT.get(code, "FAILED")
|
|
80
|
+
message = details.get("message") or state.get("state_message") or status.get("state_message")
|
|
81
|
+
return str(life), result, code, message or None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _task(raw: Mapping[str, Any]) -> TaskRunInfo:
|
|
85
|
+
life, result, code, message = _states(raw)
|
|
86
|
+
return TaskRunInfo(
|
|
87
|
+
task_key=str(raw["task_key"]),
|
|
88
|
+
run_id=int(raw["run_id"]),
|
|
89
|
+
attempt=int(raw.get("attempt_number") or 0),
|
|
90
|
+
life_cycle_state=life,
|
|
91
|
+
result_state=result,
|
|
92
|
+
termination_code=code,
|
|
93
|
+
message=message,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _run(raw: Mapping[str, Any], tasks: Sequence[Mapping[str, Any]] = ()) -> RunInfo:
|
|
98
|
+
life, result, _, _ = _states(raw)
|
|
99
|
+
params: dict[str, str] = {}
|
|
100
|
+
for p in raw.get("job_parameters") or []:
|
|
101
|
+
value = p.get("value", p.get("default"))
|
|
102
|
+
if value is not None:
|
|
103
|
+
params[str(p["name"])] = str(value)
|
|
104
|
+
repairs = [int(h["id"]) for h in raw.get("repair_history") or [] if h.get("id") is not None]
|
|
105
|
+
repair_states = {
|
|
106
|
+
int(h["id"]): _states(h)[0]
|
|
107
|
+
for h in raw.get("repair_history") or []
|
|
108
|
+
if h.get("id") is not None
|
|
109
|
+
}
|
|
110
|
+
job_id = raw.get("job_id")
|
|
111
|
+
start = raw.get("start_time")
|
|
112
|
+
return RunInfo(
|
|
113
|
+
run_id=int(raw["run_id"]),
|
|
114
|
+
job_id=None if job_id is None else int(job_id),
|
|
115
|
+
life_cycle_state=life,
|
|
116
|
+
result_state=result,
|
|
117
|
+
job_parameters=params,
|
|
118
|
+
tasks=tuple(_task(t) for t in tasks),
|
|
119
|
+
latest_repair_id=max(repairs) if repairs else None,
|
|
120
|
+
run_page_url=raw.get("run_page_url"),
|
|
121
|
+
start_time_ms=None if start is None else int(start),
|
|
122
|
+
repair_states=repair_states,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class SdkClient:
|
|
127
|
+
"""A `DatabricksClient` over the `api_client` of a `WorkspaceClient` (or a fake of it)."""
|
|
128
|
+
|
|
129
|
+
def __init__(self, api_client: _Api, host: str, redactor: Redactor | None = None) -> None:
|
|
130
|
+
self._api = api_client
|
|
131
|
+
self.host = host
|
|
132
|
+
self._redactor = redactor
|
|
133
|
+
|
|
134
|
+
def _call(
|
|
135
|
+
self,
|
|
136
|
+
method: str,
|
|
137
|
+
path: str,
|
|
138
|
+
*,
|
|
139
|
+
query: dict[str, Any] | None = None,
|
|
140
|
+
body: dict[str, Any] | None = None,
|
|
141
|
+
raw: bool = False,
|
|
142
|
+
) -> Any:
|
|
143
|
+
try:
|
|
144
|
+
return self._api.do(method, path, query=query, body=body, raw=raw)
|
|
145
|
+
except DatabricksError as err:
|
|
146
|
+
text = str(err)
|
|
147
|
+
if self._redactor is not None:
|
|
148
|
+
text = self._redactor.mask(text)
|
|
149
|
+
raise ApiError(_status(err), text, err.error_code) from None
|
|
150
|
+
|
|
151
|
+
def list_jobs(self, *, name: str | None = None) -> Iterator[JobSummary]:
|
|
152
|
+
token: str | None = None
|
|
153
|
+
while True:
|
|
154
|
+
query: dict[str, Any] = {"limit": 100}
|
|
155
|
+
if name is not None:
|
|
156
|
+
query["name"] = name
|
|
157
|
+
if token:
|
|
158
|
+
query["page_token"] = token
|
|
159
|
+
page = self._call("GET", f"{JOBS}/list", query=query) or {}
|
|
160
|
+
for job in page.get("jobs") or []:
|
|
161
|
+
settings = job.get("settings") or {}
|
|
162
|
+
tags = {str(k): str(v) for k, v in (settings.get("tags") or {}).items()}
|
|
163
|
+
yield JobSummary(int(job["job_id"]), str(settings.get("name", "")), tags)
|
|
164
|
+
token = page.get("next_page_token")
|
|
165
|
+
if not token:
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
def create_job(self, settings: Mapping[str, JsonValue]) -> int:
|
|
169
|
+
out = self._call("POST", f"{JOBS}/create", body=dict(settings))
|
|
170
|
+
return int(out["job_id"])
|
|
171
|
+
|
|
172
|
+
def reset_job(self, job_id: int, settings: Mapping[str, JsonValue]) -> None:
|
|
173
|
+
self._call("POST", f"{JOBS}/reset", body={"job_id": job_id, "new_settings": dict(settings)})
|
|
174
|
+
|
|
175
|
+
def run_now(
|
|
176
|
+
self,
|
|
177
|
+
job_id: int,
|
|
178
|
+
*,
|
|
179
|
+
job_parameters: Mapping[str, str],
|
|
180
|
+
only: Sequence[str] | None,
|
|
181
|
+
idempotency_token: str | None,
|
|
182
|
+
) -> int:
|
|
183
|
+
body: dict[str, Any] = {"job_id": job_id, "job_parameters": dict(job_parameters)}
|
|
184
|
+
if only is not None:
|
|
185
|
+
body["only"] = list(only)
|
|
186
|
+
if idempotency_token is not None:
|
|
187
|
+
body["idempotency_token"] = idempotency_token
|
|
188
|
+
return int(self._call("POST", f"{JOBS}/run-now", body=body)["run_id"])
|
|
189
|
+
|
|
190
|
+
def submit_run(self, spec: Mapping[str, JsonValue], *, idempotency_token: str | None) -> int:
|
|
191
|
+
body: dict[str, Any] = dict(spec)
|
|
192
|
+
if idempotency_token is not None:
|
|
193
|
+
body["idempotency_token"] = idempotency_token
|
|
194
|
+
return int(self._call("POST", f"{JOBS}/runs/submit", body=body)["run_id"])
|
|
195
|
+
|
|
196
|
+
def get_run(self, run_id: int) -> RunInfo:
|
|
197
|
+
query: dict[str, Any] = {"run_id": run_id, "include_history": "true"}
|
|
198
|
+
first = self._call("GET", f"{JOBS}/runs/get", query=query) or {}
|
|
199
|
+
tasks: list[Mapping[str, Any]] = list(first.get("tasks") or [])
|
|
200
|
+
token = first.get("next_page_token")
|
|
201
|
+
while token:
|
|
202
|
+
page = self._call("GET", f"{JOBS}/runs/get", query={**query, "page_token": token}) or {}
|
|
203
|
+
tasks.extend(page.get("tasks") or [])
|
|
204
|
+
token = page.get("next_page_token")
|
|
205
|
+
return _run(first, tasks)
|
|
206
|
+
|
|
207
|
+
def list_runs(self, job_id: int, *, active_only: bool = False) -> Iterator[RunInfo]:
|
|
208
|
+
token: str | None = None
|
|
209
|
+
while True:
|
|
210
|
+
query: dict[str, Any] = {"job_id": job_id, "limit": 25}
|
|
211
|
+
if active_only:
|
|
212
|
+
query["active_only"] = "true"
|
|
213
|
+
if token:
|
|
214
|
+
query["page_token"] = token
|
|
215
|
+
page = self._call("GET", f"{JOBS}/runs/list", query=query) or {}
|
|
216
|
+
for run in page.get("runs") or []:
|
|
217
|
+
yield _run(run, run.get("tasks") or [])
|
|
218
|
+
token = page.get("next_page_token")
|
|
219
|
+
if not token:
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
def repair_run(
|
|
223
|
+
self, run_id: int, *, rerun_tasks: Sequence[str], latest_repair_id: int | None
|
|
224
|
+
) -> int:
|
|
225
|
+
body: dict[str, Any] = {
|
|
226
|
+
"run_id": run_id,
|
|
227
|
+
"rerun_tasks": list(rerun_tasks),
|
|
228
|
+
"rerun_dependent_tasks": True,
|
|
229
|
+
}
|
|
230
|
+
if latest_repair_id is not None:
|
|
231
|
+
body["latest_repair_id"] = latest_repair_id
|
|
232
|
+
return int(self._call("POST", f"{JOBS}/runs/repair", body=body)["repair_id"])
|
|
233
|
+
|
|
234
|
+
def current_user(self) -> str:
|
|
235
|
+
out = self._call("GET", "/api/2.0/preview/scim/v2/Me")
|
|
236
|
+
return str(out["userName"])
|
|
237
|
+
|
|
238
|
+
def mkdirs(self, path: str) -> None:
|
|
239
|
+
self._call("POST", f"{WORKSPACE}/mkdirs", body={"path": path})
|
|
240
|
+
|
|
241
|
+
def upload(self, path: str, data: bytes) -> None:
|
|
242
|
+
body = {
|
|
243
|
+
"path": path,
|
|
244
|
+
"format": IMPORT_FORMAT,
|
|
245
|
+
"content": base64.b64encode(data).decode("ascii"),
|
|
246
|
+
"overwrite": True,
|
|
247
|
+
}
|
|
248
|
+
self._call("POST", f"{WORKSPACE}/import", body=body)
|
|
249
|
+
|
|
250
|
+
def list_dir(self, path: str) -> list[DirEntry]:
|
|
251
|
+
out = self._call("GET", f"{WORKSPACE}/list", query={"path": path}) or {}
|
|
252
|
+
return [
|
|
253
|
+
DirEntry(
|
|
254
|
+
str(o["path"]), o.get("object_type") == "DIRECTORY", int(o.get("modified_at") or 0)
|
|
255
|
+
)
|
|
256
|
+
for o in out.get("objects") or []
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
def delete(self, path: str, *, recursive: bool) -> None:
|
|
260
|
+
self._call("POST", f"{WORKSPACE}/delete", body={"path": path, "recursive": recursive})
|
|
261
|
+
|
|
262
|
+
def read_file(self, path: str) -> bytes:
|
|
263
|
+
out = self._call("GET", f"/api/2.0/fs/files{quote(path, safe='/')}", raw=True)
|
|
264
|
+
stream = out["contents"]
|
|
265
|
+
try:
|
|
266
|
+
data = stream.read()
|
|
267
|
+
finally:
|
|
268
|
+
close = getattr(stream, "close", None)
|
|
269
|
+
if close is not None:
|
|
270
|
+
close()
|
|
271
|
+
return bytes(data)
|
|
272
|
+
|
|
273
|
+
def get_secret(self, scope: str, key: str) -> str:
|
|
274
|
+
out = self._call("GET", "/api/2.0/secrets/get", query={"scope": scope, "key": key})
|
|
275
|
+
return str(out["value"])
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Direct deploy: upload the project, then create or reset the job of one pipeline.
|
|
2
|
+
|
|
3
|
+
The job's parameters name the upload folder, which is named after the content hash, so a changed
|
|
4
|
+
project changes the job settings and a second deploy of the same project is a no-op.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
from pipeloom.diagnostics import Report
|
|
16
|
+
from pipeloom.plan.model import Plan
|
|
17
|
+
from pipeloom.secrets.redaction import Redactor
|
|
18
|
+
from pipeloom_databricks.bundle import Runtime
|
|
19
|
+
from pipeloom_databricks.client import ApiError, DatabricksClient, JobSummary
|
|
20
|
+
from pipeloom_databricks.jobs_model import to_json
|
|
21
|
+
from pipeloom_databricks.render import Locations, RenderedJob, deploy_options, render_job
|
|
22
|
+
from pipeloom_databricks.upload import MANIFEST_PATH, Payload, collect, prune, upload
|
|
23
|
+
|
|
24
|
+
JOB_TAG = "pipeloom_job"
|
|
25
|
+
HASH_TAG = "pipeloom_settings_hash"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DuplicateJobs(Exception):
|
|
29
|
+
"""More than one job carries the same `pipeloom_job` tag (PLD006)."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, job_tag: str, job_ids: tuple[int, ...]) -> None:
|
|
32
|
+
super().__init__(f"{len(job_ids)} jobs carry {JOB_TAG}={job_tag}: {list(job_ids)}")
|
|
33
|
+
self.job_tag = job_tag
|
|
34
|
+
self.job_ids = job_ids
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class DeployResult:
|
|
39
|
+
job_id: int
|
|
40
|
+
action: Literal["created", "updated", "unchanged"]
|
|
41
|
+
root: str
|
|
42
|
+
"""The workspace folder that holds the uploaded project."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def find_job(
|
|
46
|
+
client: DatabricksClient, job_tag: str, *, name: str | None = None
|
|
47
|
+
) -> JobSummary | None:
|
|
48
|
+
"""The job tagged `pipeloom_job=<job_tag>`, or None.
|
|
49
|
+
|
|
50
|
+
With `name` the lookup by name goes first (cheap); when it finds no tagged job every job of
|
|
51
|
+
the workspace is scanned, because the tag, not the name, is the identity. Raises
|
|
52
|
+
`DuplicateJobs`.
|
|
53
|
+
"""
|
|
54
|
+
for lookup in ([name] if name is not None else []) + [None]:
|
|
55
|
+
hits = [j for j in client.list_jobs(name=lookup) if j.tags.get(JOB_TAG) == job_tag]
|
|
56
|
+
if len(hits) > 1:
|
|
57
|
+
raise DuplicateJobs(job_tag, tuple(sorted(j.job_id for j in hits)))
|
|
58
|
+
if hits:
|
|
59
|
+
return hits[0]
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class Prepared:
|
|
65
|
+
"""Everything a deploy decides before it talks to the workspace."""
|
|
66
|
+
|
|
67
|
+
payload: Payload
|
|
68
|
+
parent: str
|
|
69
|
+
"""`<workspace_root>/<project>/<pipeline>/<target>`: the folder of all uploads of the job."""
|
|
70
|
+
root: str
|
|
71
|
+
job: RenderedJob
|
|
72
|
+
locations: Locations
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def prepare(
|
|
76
|
+
project_dir: Path,
|
|
77
|
+
plan: Plan,
|
|
78
|
+
pipeline: str,
|
|
79
|
+
runtime: Runtime,
|
|
80
|
+
report: Report,
|
|
81
|
+
*,
|
|
82
|
+
user: Callable[[], str],
|
|
83
|
+
versions: Mapping[str, str] | None = None,
|
|
84
|
+
) -> Prepared | None:
|
|
85
|
+
"""Collect the files and render the job for their upload folder. Touches no workspace except
|
|
86
|
+
through `user`, which is called only when `workspace_root` is not set."""
|
|
87
|
+
opts = deploy_options(plan, report)
|
|
88
|
+
if opts is None:
|
|
89
|
+
return None
|
|
90
|
+
payload = collect(project_dir, plan, runtime.artifacts_root, report)
|
|
91
|
+
if payload is None:
|
|
92
|
+
return None
|
|
93
|
+
meta = plan.metadata
|
|
94
|
+
base = opts.workspace_root or f"/Workspace/Users/{user()}/.pipeloom"
|
|
95
|
+
parent = f"{base.rstrip('/')}/{meta.project}/{pipeline}/{meta.target}"
|
|
96
|
+
root = f"{parent}/{payload.digest[:12]}"
|
|
97
|
+
locs = Locations(
|
|
98
|
+
project_root=root,
|
|
99
|
+
manifest_path=f"{root}/{MANIFEST_PATH}",
|
|
100
|
+
artifacts_root=runtime.artifacts_root,
|
|
101
|
+
state_shared=runtime.state_shared,
|
|
102
|
+
cancel_grace=runtime.cancel_grace,
|
|
103
|
+
)
|
|
104
|
+
job = render_job(plan, pipeline, locs, report, versions=versions)
|
|
105
|
+
return None if job is None else Prepared(payload, parent, root, job, locs)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@contextmanager
|
|
109
|
+
def api_errors(report: Report, redactor: Redactor | None = None) -> Iterator[None]:
|
|
110
|
+
"""Turn `DuplicateJobs` into PLD006 and `ApiError` into PLD009 in `report`; the block then
|
|
111
|
+
ends early (the caller returns None)."""
|
|
112
|
+
try:
|
|
113
|
+
yield
|
|
114
|
+
except DuplicateJobs as err:
|
|
115
|
+
report.add(
|
|
116
|
+
"PLD006",
|
|
117
|
+
f"{len(err.job_ids)} jobs carry {JOB_TAG}={err.job_tag}: ids {list(err.job_ids)}",
|
|
118
|
+
hint="delete or untag the extra jobs",
|
|
119
|
+
)
|
|
120
|
+
except ApiError as err:
|
|
121
|
+
text = redactor.mask(err.message) if redactor else err.message
|
|
122
|
+
code = f", {err.error_code}" if err.error_code else ""
|
|
123
|
+
report.add(
|
|
124
|
+
"PLD009",
|
|
125
|
+
f"Databricks API request failed (HTTP {err.status}{code}): {text}",
|
|
126
|
+
context={"status": err.status},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def deploy_prepared(
|
|
131
|
+
client: DatabricksClient,
|
|
132
|
+
prepared: Prepared,
|
|
133
|
+
existing: JobSummary | None,
|
|
134
|
+
*,
|
|
135
|
+
report: Report,
|
|
136
|
+
redactor: Redactor | None = None,
|
|
137
|
+
) -> DeployResult:
|
|
138
|
+
"""Upload and create or reset the job of `prepared`; `existing` is the job found by tag.
|
|
139
|
+
|
|
140
|
+
Raises `ApiError`; wrap the call in `api_errors`.
|
|
141
|
+
"""
|
|
142
|
+
job, root = prepared.job, prepared.root
|
|
143
|
+
if existing is not None and existing.tags.get(HASH_TAG) == job.settings_hash:
|
|
144
|
+
return DeployResult(existing.job_id, "unchanged", root)
|
|
145
|
+
upload(client, prepared.payload, root)
|
|
146
|
+
settings = to_json(job.settings)
|
|
147
|
+
if existing is None:
|
|
148
|
+
result = DeployResult(client.create_job(settings), "created", root)
|
|
149
|
+
else:
|
|
150
|
+
client.reset_job(existing.job_id, settings)
|
|
151
|
+
result = DeployResult(existing.job_id, "updated", root)
|
|
152
|
+
prune(
|
|
153
|
+
client,
|
|
154
|
+
prepared.parent,
|
|
155
|
+
prepared.payload.digest[:12],
|
|
156
|
+
report,
|
|
157
|
+
mask=redactor.mask if redactor else str,
|
|
158
|
+
)
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def deploy(
|
|
163
|
+
client: DatabricksClient,
|
|
164
|
+
project_dir: Path,
|
|
165
|
+
plan: Plan,
|
|
166
|
+
pipeline: str,
|
|
167
|
+
runtime: Runtime,
|
|
168
|
+
*,
|
|
169
|
+
report: Report,
|
|
170
|
+
redactor: Redactor | None = None,
|
|
171
|
+
versions: Mapping[str, str] | None = None,
|
|
172
|
+
) -> DeployResult | None:
|
|
173
|
+
"""Deploy one pipeline, or return None with PLD004, PLD006, PLD009 or PLD012 in `report`."""
|
|
174
|
+
with api_errors(report, redactor):
|
|
175
|
+
prepared = prepare(
|
|
176
|
+
project_dir,
|
|
177
|
+
plan,
|
|
178
|
+
pipeline,
|
|
179
|
+
runtime,
|
|
180
|
+
report,
|
|
181
|
+
user=client.current_user,
|
|
182
|
+
versions=versions,
|
|
183
|
+
)
|
|
184
|
+
if prepared is None:
|
|
185
|
+
return None
|
|
186
|
+
existing = find_job(client, prepared.job.job_tag, name=prepared.job.settings.name)
|
|
187
|
+
return deploy_prepared(client, prepared, existing, report=report, redactor=redactor)
|
|
188
|
+
return None
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""What the Jobs API knows about a run's tasks, as the unit states core finalize accepts.
|
|
2
|
+
|
|
3
|
+
A unit whose task died (a lost cluster, a timeout, a cancel) wrote no attempt file. The finalize
|
|
4
|
+
task asks the Jobs API how each task ended and hands the answer to `pipeloom finalize
|
|
5
|
+
--unit-states`, so the run fails (or is cancelled) instead of looking like a success.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Collection, Mapping
|
|
12
|
+
|
|
13
|
+
from pipeloom.runtime.finalize_run import UNIT_STATES_SCHEMA, ExternalUnitState
|
|
14
|
+
from pipeloom_databricks.client import DatabricksClient, RunInfo, TaskRunInfo
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def task_states(client: DatabricksClient, job_run_id: int) -> Mapping[str, TaskRunInfo]:
|
|
18
|
+
"""The tasks of a job run by task key (the latest attempt of each)."""
|
|
19
|
+
return {t.task_key: t for t in client.get_run(job_run_id).tasks}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def unit_states(
|
|
23
|
+
key_map: Mapping[str, str], run: RunInfo, with_attempts: Collection[str]
|
|
24
|
+
) -> dict[str, ExternalUnitState]:
|
|
25
|
+
"""The external state of every unit that has no attempt file.
|
|
26
|
+
|
|
27
|
+
`key_map` is unit id -> task key. EXCLUDED is `excluded`; UPSTREAM_FAILED and
|
|
28
|
+
UPSTREAM_CANCELED are `skipped` (upstream_failed); CANCELED and TIMEDOUT are `cancelled`; FAILED
|
|
29
|
+
is `failed` with PLD011 and the termination message. Any other state (success, still running,
|
|
30
|
+
unknown task) gives no entry, so core finalize counts the unit as not run.
|
|
31
|
+
"""
|
|
32
|
+
tasks = {t.task_key: t for t in run.tasks}
|
|
33
|
+
out: dict[str, ExternalUnitState] = {}
|
|
34
|
+
for unit, key in sorted(key_map.items()):
|
|
35
|
+
task = tasks.get(key)
|
|
36
|
+
if unit in with_attempts or task is None:
|
|
37
|
+
continue
|
|
38
|
+
ids = {
|
|
39
|
+
"databricks_task_key": task.task_key,
|
|
40
|
+
"databricks_task_run_id": str(task.run_id),
|
|
41
|
+
}
|
|
42
|
+
match task.result_state:
|
|
43
|
+
case "EXCLUDED":
|
|
44
|
+
out[unit] = ExternalUnitState("excluded")
|
|
45
|
+
case "UPSTREAM_FAILED" | "UPSTREAM_CANCELED":
|
|
46
|
+
out[unit] = ExternalUnitState("skipped", orchestrator_ids=ids)
|
|
47
|
+
case "CANCELED" | "TIMEDOUT":
|
|
48
|
+
out[unit] = ExternalUnitState("cancelled", orchestrator_ids=ids)
|
|
49
|
+
case "FAILED":
|
|
50
|
+
reason = task.message or task.termination_code or "no reason given"
|
|
51
|
+
out[unit] = ExternalUnitState(
|
|
52
|
+
"failed",
|
|
53
|
+
code="PLD011",
|
|
54
|
+
message=f"task {task.task_key} ended without a unit result: {reason}",
|
|
55
|
+
orchestrator_ids=ids,
|
|
56
|
+
)
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def unit_states_json(states: Mapping[str, ExternalUnitState]) -> bytes:
|
|
61
|
+
"""The `pipeloom.unit_states/1` file for `states`."""
|
|
62
|
+
units: dict[str, dict[str, object]] = {}
|
|
63
|
+
for unit, state in sorted(states.items()):
|
|
64
|
+
doc: dict[str, object] = {"status": state.status}
|
|
65
|
+
for name in ("reason", "code", "message"):
|
|
66
|
+
if getattr(state, name) is not None:
|
|
67
|
+
doc[name] = getattr(state, name)
|
|
68
|
+
if state.orchestrator_ids:
|
|
69
|
+
doc["orchestrator_ids"] = dict(state.orchestrator_ids)
|
|
70
|
+
units[unit] = doc
|
|
71
|
+
doc_text = json.dumps({"schema": UNIT_STATES_SCHEMA, "units": units}, ensure_ascii=True)
|
|
72
|
+
return (doc_text + "\n").encode("ascii")
|