databricks-mason 0.1.0.dev0__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.
- databricks_mason/__init__.py +1 -0
- databricks_mason/auth.py +83 -0
- databricks_mason/cli.py +72 -0
- databricks_mason/client.py +298 -0
- databricks_mason/deploy.py +325 -0
- databricks_mason/errors.py +53 -0
- databricks_mason/memory.py +347 -0
- databricks_mason/render.py +185 -0
- databricks_mason/sessions.py +442 -0
- databricks_mason/timefmt.py +85 -0
- databricks_mason/tracing.py +263 -0
- databricks_mason-0.1.0.dev0.dist-info/METADATA +80 -0
- databricks_mason-0.1.0.dev0.dist-info/RECORD +15 -0
- databricks_mason-0.1.0.dev0.dist-info/WHEEL +4 -0
- databricks_mason-0.1.0.dev0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""`mason deploy` and the `mason deployments` group — manage agent deployments.
|
|
2
|
+
|
|
3
|
+
`mason deploy` is the integrated entry point: it can provision a memory store and a
|
|
4
|
+
session store for the agent, inject their identifiers into the deployment's `app.yaml`
|
|
5
|
+
env, then roll out the deployment. `mason deployments` covers the lifecycle verbs
|
|
6
|
+
(`list`/`get`/`logs`/`start`/`stop`/`delete`).
|
|
7
|
+
|
|
8
|
+
Deployments run on the Databricks Apps runtime, which this module drives via the
|
|
9
|
+
`databricks apps` CLI — an implementation detail that is not part of Mason's surface.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import pathlib
|
|
16
|
+
import subprocess
|
|
17
|
+
from typing import Any, Optional
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
22
|
+
from databricks_mason import render, timefmt
|
|
23
|
+
from databricks_mason.errors import AgentCliError
|
|
24
|
+
from databricks_mason.render import field
|
|
25
|
+
from databricks_mason.tracing import TRACES_DEST_ENV, TRACES_EXPERIMENT_ENV
|
|
26
|
+
|
|
27
|
+
_MEMORY_ENV = "AGENT_MEMORY_STORE"
|
|
28
|
+
_SESSION_ENV = "AGENT_SESSION_STORE"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# --- databricks CLI plumbing (the deployment runtime) -----------------------
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _databricks(
|
|
35
|
+
args: list[str], profile: Optional[str], *, capture: bool = False, check: bool = True
|
|
36
|
+
) -> subprocess.CompletedProcess:
|
|
37
|
+
cmd = ["databricks", *args]
|
|
38
|
+
if profile:
|
|
39
|
+
cmd += ["--profile", profile]
|
|
40
|
+
result = subprocess.run(cmd, text=True, capture_output=capture)
|
|
41
|
+
if check and result.returncode != 0:
|
|
42
|
+
detail = (result.stderr or result.stdout or "").strip() if capture else None
|
|
43
|
+
raise AgentCliError(f"`{' '.join(cmd)}` failed (exit {result.returncode})", hint=detail)
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _deployment_exists(name: str, profile: Optional[str]) -> bool:
|
|
48
|
+
return _databricks(["apps", "get", name], profile, capture=True, check=False).returncode == 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# --- app.yaml manifest handling ---------------------------------------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _upsert_manifest_env(source: pathlib.Path, updates: dict[str, str]) -> bool:
|
|
55
|
+
"""Inject/overwrite env entries in <source>/app.yaml. Returns True if it scaffolded a new file."""
|
|
56
|
+
app_yaml = source / "app.yaml"
|
|
57
|
+
if app_yaml.exists():
|
|
58
|
+
loaded = yaml.safe_load(app_yaml.read_text())
|
|
59
|
+
doc: dict[str, Any] = loaded if isinstance(loaded, dict) else {}
|
|
60
|
+
scaffolded = False
|
|
61
|
+
else:
|
|
62
|
+
doc = {"command": ["# TODO: set your run command, e.g. ['uvicorn', 'app:app']"], "env": []}
|
|
63
|
+
scaffolded = True
|
|
64
|
+
|
|
65
|
+
raw_env = doc.get("env")
|
|
66
|
+
env: list[dict[str, Any]] = (
|
|
67
|
+
[entry for entry in raw_env if isinstance(entry, dict)] if isinstance(raw_env, list) else []
|
|
68
|
+
)
|
|
69
|
+
by_name = {e.get("name"): e for e in env if isinstance(e, dict)}
|
|
70
|
+
for name, value in updates.items():
|
|
71
|
+
if name in by_name:
|
|
72
|
+
by_name[name]["value"] = value
|
|
73
|
+
by_name[name].pop("valueFrom", None)
|
|
74
|
+
else:
|
|
75
|
+
env.append({"name": name, "value": value})
|
|
76
|
+
doc["env"] = env
|
|
77
|
+
app_yaml.write_text(yaml.safe_dump(doc, sort_keys=False))
|
|
78
|
+
return scaffolded
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# --- store provisioning -----------------------------------------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _ensure_memory_store(client, display_name: str) -> dict:
|
|
85
|
+
try:
|
|
86
|
+
return client.create_memory_store(display_name)
|
|
87
|
+
except AgentCliError as exc:
|
|
88
|
+
if exc.error_code != "ALREADY_EXISTS":
|
|
89
|
+
raise
|
|
90
|
+
listing = client.list_memory_stores(page_size=1000)
|
|
91
|
+
for store in field(listing, "managed_memory_stores") or []:
|
|
92
|
+
if field(store, "display_name") == display_name:
|
|
93
|
+
return store
|
|
94
|
+
raise AgentCliError(f"Memory store '{display_name}' exists but could not be resolved.")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _ensure_session_store(client, name: str) -> dict:
|
|
98
|
+
try:
|
|
99
|
+
return client.create_session_store(name)
|
|
100
|
+
except AgentCliError as exc:
|
|
101
|
+
if exc.error_code != "ALREADY_EXISTS":
|
|
102
|
+
raise
|
|
103
|
+
return client.get_session_store(name)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --- mason deploy -----------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@click.command()
|
|
110
|
+
@click.argument("name")
|
|
111
|
+
@click.option(
|
|
112
|
+
"--source",
|
|
113
|
+
required=True,
|
|
114
|
+
type=click.Path(exists=True, file_okay=False),
|
|
115
|
+
help="Local source directory for the deployment (containing app.yaml).",
|
|
116
|
+
)
|
|
117
|
+
@click.option(
|
|
118
|
+
"--with-memory-store",
|
|
119
|
+
"memory_store",
|
|
120
|
+
default=None,
|
|
121
|
+
help="Memory store display name to wire in via AGENT_MEMORY_STORE.",
|
|
122
|
+
)
|
|
123
|
+
@click.option(
|
|
124
|
+
"--with-session-store",
|
|
125
|
+
"session_store",
|
|
126
|
+
default=None,
|
|
127
|
+
help="Session store name to wire in via AGENT_SESSION_STORE.",
|
|
128
|
+
)
|
|
129
|
+
@click.option(
|
|
130
|
+
"--with-traces",
|
|
131
|
+
"traces_destination",
|
|
132
|
+
default=None,
|
|
133
|
+
help="UC trace destination 'catalog.schema' to wire in via MLFLOW_TRACING_DESTINATION "
|
|
134
|
+
"(link it first with `mason tracing setup`).",
|
|
135
|
+
)
|
|
136
|
+
@click.option(
|
|
137
|
+
"--traces-experiment",
|
|
138
|
+
default=None,
|
|
139
|
+
help="MLflow experiment path to wire in via MLFLOW_EXPERIMENT_NAME.",
|
|
140
|
+
)
|
|
141
|
+
@click.option(
|
|
142
|
+
"--create-stores",
|
|
143
|
+
is_flag=True,
|
|
144
|
+
help="Create the referenced stores if they don't exist (idempotent).",
|
|
145
|
+
)
|
|
146
|
+
@click.option(
|
|
147
|
+
"--workspace-path",
|
|
148
|
+
default=None,
|
|
149
|
+
help="Workspace destination for the synced source (defaults to a per-user path).",
|
|
150
|
+
)
|
|
151
|
+
@click.pass_obj
|
|
152
|
+
def deploy(
|
|
153
|
+
obj,
|
|
154
|
+
name,
|
|
155
|
+
source,
|
|
156
|
+
memory_store,
|
|
157
|
+
session_store,
|
|
158
|
+
traces_destination,
|
|
159
|
+
traces_experiment,
|
|
160
|
+
create_stores,
|
|
161
|
+
workspace_path,
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Deploy an agent: provision its stores, wire them in, and roll out the deployment."""
|
|
164
|
+
source_dir = pathlib.Path(source)
|
|
165
|
+
client = obj.client()
|
|
166
|
+
|
|
167
|
+
# 1. Provision / resolve stores and build the env to inject.
|
|
168
|
+
env_updates: dict[str, str] = {}
|
|
169
|
+
provisioned: dict[str, Any] = {}
|
|
170
|
+
if memory_store:
|
|
171
|
+
store = (
|
|
172
|
+
_ensure_memory_store(client, memory_store)
|
|
173
|
+
if create_stores
|
|
174
|
+
else client.get_memory_store(memory_store)
|
|
175
|
+
)
|
|
176
|
+
env_updates[_MEMORY_ENV] = field(store, "name") or memory_store
|
|
177
|
+
provisioned["Memory store"] = env_updates[_MEMORY_ENV]
|
|
178
|
+
if session_store:
|
|
179
|
+
if create_stores:
|
|
180
|
+
_ensure_session_store(client, session_store)
|
|
181
|
+
env_updates[_SESSION_ENV] = session_store
|
|
182
|
+
provisioned["Session store"] = session_store
|
|
183
|
+
if traces_destination:
|
|
184
|
+
env_updates[TRACES_DEST_ENV] = traces_destination
|
|
185
|
+
provisioned["Traces"] = traces_destination
|
|
186
|
+
if traces_experiment:
|
|
187
|
+
env_updates[TRACES_EXPERIMENT_ENV] = traces_experiment
|
|
188
|
+
|
|
189
|
+
# 2. Patch the app.yaml manifest with the store identifiers.
|
|
190
|
+
scaffolded = False
|
|
191
|
+
if env_updates:
|
|
192
|
+
scaffolded = _upsert_manifest_env(source_dir, env_updates)
|
|
193
|
+
|
|
194
|
+
# 3. Roll out the deployment (Databricks Apps runtime).
|
|
195
|
+
if not _deployment_exists(name, obj.profile):
|
|
196
|
+
_databricks(["apps", "create", name], obj.profile)
|
|
197
|
+
ws_path = workspace_path or f"/Workspace/Users/{client.current_user}/mason_deployments/{name}"
|
|
198
|
+
_databricks(["sync", str(source_dir), ws_path], obj.profile)
|
|
199
|
+
_databricks(["apps", "deploy", name, "--source-code-path", ws_path], obj.profile)
|
|
200
|
+
|
|
201
|
+
if obj.output == "json":
|
|
202
|
+
render.emit_json({"deployment": name, "workspace_path": ws_path, "env": env_updates})
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
steps = [f"mason deployments logs {name}", f"mason deployments get {name}"]
|
|
206
|
+
if scaffolded:
|
|
207
|
+
steps.insert(
|
|
208
|
+
0, f"Set a real `command:` in {source_dir / 'app.yaml'} (a placeholder was written)"
|
|
209
|
+
)
|
|
210
|
+
render.success(
|
|
211
|
+
f"Deployed agent '{name}'",
|
|
212
|
+
fields={"Workspace path": ws_path, **provisioned},
|
|
213
|
+
next_steps=steps,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# --- mason deployments <lifecycle> ------------------------------------------
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@click.group()
|
|
221
|
+
def deployments() -> None:
|
|
222
|
+
"""Manage agent deployments."""
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _deployment_status(a: dict) -> Optional[str]:
|
|
226
|
+
for key in ("app_status", "compute_status"):
|
|
227
|
+
section = a.get(key)
|
|
228
|
+
if isinstance(section, dict) and field(section, "state"):
|
|
229
|
+
return field(section, "state")
|
|
230
|
+
return field(a, "state")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@deployments.command("list")
|
|
234
|
+
@click.pass_obj
|
|
235
|
+
def deployments_list(obj) -> None:
|
|
236
|
+
"""List agent deployments in the workspace."""
|
|
237
|
+
result = _databricks(["apps", "list", "-o", "json"], obj.profile, capture=True)
|
|
238
|
+
data = json.loads(result.stdout or "[]")
|
|
239
|
+
items = data.get("apps", data) if isinstance(data, dict) else data
|
|
240
|
+
if obj.output == "json":
|
|
241
|
+
render.emit_json(items)
|
|
242
|
+
return
|
|
243
|
+
rows = [
|
|
244
|
+
[
|
|
245
|
+
field(a, "name"),
|
|
246
|
+
render.status_pill(_deployment_status(a)),
|
|
247
|
+
field(a, "url"),
|
|
248
|
+
timefmt.relative(field(a, "update_time")),
|
|
249
|
+
]
|
|
250
|
+
for a in items
|
|
251
|
+
]
|
|
252
|
+
render.resource_table(
|
|
253
|
+
"Agent Deployments",
|
|
254
|
+
[("Name", "left"), ("Status", "left"), ("URL", "left"), ("Updated", "left")],
|
|
255
|
+
rows,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@deployments.command("get")
|
|
260
|
+
@click.argument("name")
|
|
261
|
+
@click.pass_obj
|
|
262
|
+
def deployments_get(obj, name) -> None:
|
|
263
|
+
"""Get an agent deployment's details."""
|
|
264
|
+
result = _databricks(["apps", "get", name, "-o", "json"], obj.profile, capture=True)
|
|
265
|
+
data = json.loads(result.stdout or "{}")
|
|
266
|
+
if obj.output == "json":
|
|
267
|
+
render.emit_json(data)
|
|
268
|
+
return
|
|
269
|
+
url = field(data, "url")
|
|
270
|
+
render.detail(
|
|
271
|
+
"Agent Deployment",
|
|
272
|
+
field(data, "name") or name,
|
|
273
|
+
{
|
|
274
|
+
"URL": url,
|
|
275
|
+
"Description": field(data, "description"),
|
|
276
|
+
"Created": timefmt.absolute(field(data, "create_time")),
|
|
277
|
+
"Updated": timefmt.absolute(field(data, "update_time")),
|
|
278
|
+
},
|
|
279
|
+
status=_deployment_status(data),
|
|
280
|
+
snippets=[("open", "bash", f"open {url}")] if url else None,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@deployments.command("logs")
|
|
285
|
+
@click.argument("name")
|
|
286
|
+
@click.pass_obj
|
|
287
|
+
def deployments_logs(obj, name) -> None:
|
|
288
|
+
"""Stream a deployment's logs."""
|
|
289
|
+
_databricks(["apps", "logs", name], obj.profile)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@deployments.command("start")
|
|
293
|
+
@click.argument("name")
|
|
294
|
+
@click.pass_obj
|
|
295
|
+
def deployments_start(obj, name) -> None:
|
|
296
|
+
"""Start a deployment."""
|
|
297
|
+
_databricks(["apps", "start", name], obj.profile)
|
|
298
|
+
if obj.output == "json":
|
|
299
|
+
render.emit_json({"started": name})
|
|
300
|
+
return
|
|
301
|
+
render.success(f"Started deployment '{name}'")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@deployments.command("stop")
|
|
305
|
+
@click.argument("name")
|
|
306
|
+
@click.pass_obj
|
|
307
|
+
def deployments_stop(obj, name) -> None:
|
|
308
|
+
"""Stop a deployment."""
|
|
309
|
+
_databricks(["apps", "stop", name], obj.profile)
|
|
310
|
+
if obj.output == "json":
|
|
311
|
+
render.emit_json({"stopped": name})
|
|
312
|
+
return
|
|
313
|
+
render.success(f"Stopped deployment '{name}'")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@deployments.command("delete")
|
|
317
|
+
@click.argument("name")
|
|
318
|
+
@click.pass_obj
|
|
319
|
+
def deployments_delete(obj, name) -> None:
|
|
320
|
+
"""Delete a deployment."""
|
|
321
|
+
_databricks(["apps", "delete", name], obj.profile)
|
|
322
|
+
if obj.output == "json":
|
|
323
|
+
render.emit_json({"deleted": name})
|
|
324
|
+
return
|
|
325
|
+
render.success(f"Deleted deployment '{name}'")
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Error types for the Mason CLI and mapping from Databricks REST errors.
|
|
2
|
+
|
|
3
|
+
`AgentCliError` extends `click.ClickException` so a raised error prints as a clean
|
|
4
|
+
one-liner (plus an optional hint) and exits non-zero, instead of dumping a traceback.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
# Error codes indicating that a preview API is unavailable in the workspace.
|
|
16
|
+
_PREVIEW_ERROR_CODES = frozenset({"NOT_IMPLEMENTED", "UNIMPLEMENTED", "FEATURE_DISABLED"})
|
|
17
|
+
|
|
18
|
+
_PREVIEW_HINT = (
|
|
19
|
+
"These agents/v1 APIs are in preview and gated per workspace. This handler is "
|
|
20
|
+
"not enabled on the target workspace yet — try a different --profile or contact "
|
|
21
|
+
"your workspace administrator."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AgentCliError(click.ClickException):
|
|
26
|
+
"""A user-facing CLI error rendered without a Python traceback."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self, message: str, *, error_code: Optional[str] = None, hint: Optional[str] = None
|
|
30
|
+
):
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.error_code = error_code
|
|
33
|
+
self.hint = hint
|
|
34
|
+
|
|
35
|
+
def show(self, file=None) -> None:
|
|
36
|
+
console = Console(stderr=True)
|
|
37
|
+
label = f"Error [{self.error_code}]" if self.error_code else "Error"
|
|
38
|
+
console.print(Text(f"{label}: ", style="bold red") + Text(self.message))
|
|
39
|
+
if self.hint:
|
|
40
|
+
console.print(Text(self.hint, style="grey62"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def wrap_api_error(exc: Exception) -> AgentCliError:
|
|
44
|
+
"""Convert a databricks-sdk error (or any exception) into an `AgentCliError`.
|
|
45
|
+
|
|
46
|
+
The SDK raises `databricks.sdk.errors.DatabricksError` subclasses carrying an
|
|
47
|
+
`error_code` attribute; we stay duck-typed so we don't couple to the SDK's error
|
|
48
|
+
hierarchy or version.
|
|
49
|
+
"""
|
|
50
|
+
error_code = getattr(exc, "error_code", None)
|
|
51
|
+
message = str(exc).strip() or exc.__class__.__name__
|
|
52
|
+
hint = _PREVIEW_HINT if error_code in _PREVIEW_ERROR_CODES else None
|
|
53
|
+
return AgentCliError(message, error_code=error_code, hint=hint)
|