db-agent-kit 0.2.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.
- db_agent/__init__.py +3 -0
- db_agent/cli.py +32 -0
- db_agent/engines/__init__.py +36 -0
- db_agent/engines/databricks/__init__.py +1 -0
- db_agent/engines/databricks/checks.py +221 -0
- db_agent/engines/databricks/cli.py +61 -0
- db_agent/engines/databricks/client.py +86 -0
- db_agent/engines/databricks/cost.py +96 -0
- db_agent/engines/databricks/health.py +156 -0
- db_agent/engines/databricks/model.py +29 -0
- db_agent/engines/databricks/queries.py +98 -0
- db_agent/engines/databricks/render.py +78 -0
- db_agent/engines/databricks/runtimes.py +58 -0
- db_agent/engines/databricks/sqlguard.py +80 -0
- db_agent/engines/sqlserver/__init__.py +11 -0
- db_agent/engines/sqlserver/classify.py +102 -0
- db_agent/engines/sqlserver/cli.py +265 -0
- db_agent/engines/sqlserver/data/classification.yaml +29 -0
- db_agent/engines/sqlserver/db.py +183 -0
- db_agent/engines/sqlserver/guard.py +190 -0
- db_agent/engines/sqlserver/inventory.py +324 -0
- db_agent/engines/sqlserver/outputs.py +299 -0
- db_agent/engines/sqlserver/rdl.py +269 -0
- db_agent/engines/sqlserver/report.py +120 -0
- db_agent/engines/sqlserver/risks.py +184 -0
- db_agent/engines/sqlserver/rules.py +141 -0
- db_agent/engines/sqlserver/sqlrefs.py +103 -0
- db_agent/engines/sqlserver/ssrs.py +230 -0
- db_agent/engines/sqlserver/templates/summary.html.j2 +142 -0
- db_agent/engines/sqlserver/usage.py +127 -0
- db_agent/engines/sqlserver/validate.py +238 -0
- db_agent_kit-0.2.0.dist-info/METADATA +192 -0
- db_agent_kit-0.2.0.dist-info/RECORD +36 -0
- db_agent_kit-0.2.0.dist-info/WHEEL +4 -0
- db_agent_kit-0.2.0.dist-info/entry_points.txt +2 -0
- db_agent_kit-0.2.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Collect, run the checks, assemble the Report. Each collection step fails on its own: a missing permission
|
|
2
|
+
on one system table never hides the other results; it shows up under "not collected"."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import date, datetime, timezone
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
from db_agent import __version__
|
|
11
|
+
|
|
12
|
+
from . import checks, cost as cost_mod, queries, runtimes
|
|
13
|
+
from .model import Finding, Report
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Options:
|
|
18
|
+
today: date
|
|
19
|
+
lookback_days: int = 30
|
|
20
|
+
cost_from: str | None = None
|
|
21
|
+
cost_to: str | None = None
|
|
22
|
+
eos_warning_days: int = 180
|
|
23
|
+
all_workspaces: bool = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _num(v):
|
|
27
|
+
return None if v is None else float(v)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run(reader, opt: Options) -> Report:
|
|
31
|
+
rep = Report(meta={"tool": f"db-agent {__version__}", "generated_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
32
|
+
"lookback_days": opt.lookback_days, "runtime_table_source": runtimes.SOURCE,
|
|
33
|
+
"prices": "list price (system.billing.list_prices), before discounts"})
|
|
34
|
+
|
|
35
|
+
def step(name: str, fn: Callable, default=None):
|
|
36
|
+
try:
|
|
37
|
+
return fn()
|
|
38
|
+
except Exception as e: # noqa: BLE001 - every failure is reported, none is fatal
|
|
39
|
+
rep.not_collected.append({"check": name, "reason": f"{type(e).__name__}: {str(e)[:300]}"})
|
|
40
|
+
return default
|
|
41
|
+
|
|
42
|
+
wid = step("workspace id", reader.workspace_id)
|
|
43
|
+
rep.meta["workspace_id"] = wid
|
|
44
|
+
rep.meta["host"] = step("host", reader.host)
|
|
45
|
+
ws_filter = None if opt.all_workspaces else wid
|
|
46
|
+
if not opt.all_workspaces and wid is None:
|
|
47
|
+
rep.not_collected.append({"check": "system table queries", "reason": "workspace id unknown; pass --all-workspaces to include every workspace in the account"})
|
|
48
|
+
sql_ok = reader.has_sql and (opt.all_workspaces or wid is not None)
|
|
49
|
+
rep.meta["warehouse_id"] = reader.warehouse_id if sql_ok else None
|
|
50
|
+
if not reader.has_sql:
|
|
51
|
+
why = "skipped by --no-sql" if getattr(reader, "sql_skipped", False) else "no SQL warehouse is running; pass --warehouse-id (a stopped one will start and bill)"
|
|
52
|
+
rep.not_collected.append({"check": "system table queries", "reason": why})
|
|
53
|
+
|
|
54
|
+
clusters = step("clusters (REST)", reader.clusters, [])
|
|
55
|
+
jobs = step("jobs (REST)", reader.jobs, [])
|
|
56
|
+
warehouses = step("SQL warehouses (REST)", reader.warehouses, [])
|
|
57
|
+
rep.meta["counts"] = {"clusters": len(clusters), "jobs": len(jobs), "warehouses": len(warehouses)}
|
|
58
|
+
|
|
59
|
+
# 1. runtimes
|
|
60
|
+
rep.findings += checks.runtime_findings(clusters, jobs, opt.today, opt.eos_warning_days)
|
|
61
|
+
|
|
62
|
+
# SQL-backed data
|
|
63
|
+
def q(name, sql):
|
|
64
|
+
return step(name, lambda: reader.sql(sql), None) if sql_ok else None
|
|
65
|
+
|
|
66
|
+
cluster_spend = q("spend by cluster", queries.spend_by_cluster(opt.lookback_days, ws_filter))
|
|
67
|
+
cluster_spend = {r["cluster_id"]: r for r in cluster_spend} if cluster_spend is not None else None
|
|
68
|
+
wh_spend = q("spend by warehouse", queries.spend_by_warehouse(opt.lookback_days, ws_filter))
|
|
69
|
+
wh_spend = {r["warehouse_id"]: r for r in wh_spend} if wh_spend is not None else None
|
|
70
|
+
wh_queries = q("queries by warehouse (system.query.history)", queries.queries_by_warehouse(opt.lookback_days, ws_filter))
|
|
71
|
+
wh_queries = {r["warehouse_id"]: r for r in wh_queries} if wh_queries is not None else None
|
|
72
|
+
untagged = q("untagged spend", queries.untagged_spend(opt.lookback_days, ws_filter))
|
|
73
|
+
mounts = q("DBFS mount events (system.access.audit)", queries.dbfs_mount_events(365, ws_filter))
|
|
74
|
+
|
|
75
|
+
hms = None
|
|
76
|
+
if sql_ok:
|
|
77
|
+
def hms_counts():
|
|
78
|
+
try:
|
|
79
|
+
schemas = [list(r.values())[0] for r in reader.sql(queries.SHOW_SCHEMAS)]
|
|
80
|
+
except Exception as e: # noqa: BLE001
|
|
81
|
+
if "HIVE_METASTORE_DISABLED" in str(e):
|
|
82
|
+
rep.findings.append(Finding("legacy.hive_metastore_disabled", "info", "Hive metastore is disabled in this workspace",
|
|
83
|
+
"Legacy access to hive_metastore is turned off, so there are no Hive metastore tables to migrate here.", [], ""))
|
|
84
|
+
return {}
|
|
85
|
+
raise
|
|
86
|
+
if len(schemas) > 300:
|
|
87
|
+
rep.not_collected.append({"check": "Hive metastore tables", "reason": f"{len(schemas)} schemas; counted the first 300"})
|
|
88
|
+
schemas = schemas[:300]
|
|
89
|
+
out = {}
|
|
90
|
+
for s in schemas:
|
|
91
|
+
if s == "information_schema":
|
|
92
|
+
continue
|
|
93
|
+
rows = reader.sql(queries.show_tables(s))
|
|
94
|
+
out[s] = sum(1 for r in rows if str(r.get("isTemporary", "false")).lower() != "true")
|
|
95
|
+
return out
|
|
96
|
+
hms = step("Hive metastore tables (SHOW TABLES)", hms_counts, None)
|
|
97
|
+
|
|
98
|
+
# 2. legacy dependencies
|
|
99
|
+
rep.findings += checks.hms_findings(hms)
|
|
100
|
+
rep.findings += checks.mount_findings(mounts, 365)
|
|
101
|
+
rep.findings += checks.init_script_findings(clusters, jobs)
|
|
102
|
+
rep.findings += checks.isolation_findings(clusters, jobs)
|
|
103
|
+
|
|
104
|
+
# 3. cost over time
|
|
105
|
+
if sql_ok:
|
|
106
|
+
a, b = (opt.cost_from, opt.cost_to) if opt.cost_from and opt.cost_to else cost_mod.default_range(opt.today)
|
|
107
|
+
def cost_step():
|
|
108
|
+
months = cost_mod.month_list(a, b)
|
|
109
|
+
last = months[-1]
|
|
110
|
+
end = date(last.year + (last.month == 12), last.month % 12 + 1, 1)
|
|
111
|
+
return cost_mod.analyze(reader.sql(queries.cost_by_month(months[0], end, ws_filter)), opt.today)
|
|
112
|
+
res = step("DBU spend by month", cost_step, None)
|
|
113
|
+
if res is not None:
|
|
114
|
+
rep.cost = res
|
|
115
|
+
rep.findings.append(_cost_finding(res, a, b))
|
|
116
|
+
|
|
117
|
+
# 4. waste
|
|
118
|
+
rep.findings += checks.jobs_on_all_purpose(jobs, clusters, cluster_spend, opt.lookback_days)
|
|
119
|
+
rep.findings += checks.no_autotermination(clusters, cluster_spend, opt.lookback_days)
|
|
120
|
+
rep.findings += checks.warehouse_findings(warehouses, wh_spend, wh_queries, opt.lookback_days)
|
|
121
|
+
rep.findings += checks.untagged_findings(untagged, opt.lookback_days)
|
|
122
|
+
|
|
123
|
+
order = {"high": 0, "medium": 1, "low": 2, "info": 3}
|
|
124
|
+
rep.findings.sort(key=lambda f: order[f.severity])
|
|
125
|
+
return rep
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _usd(x: float, signed: bool = False) -> str:
|
|
129
|
+
s = f"{abs(x):,.2f}" if abs(x) < 100 else f"{abs(x):,.0f}"
|
|
130
|
+
sign = ("+" if x >= 0 else "-") if signed else ("-" if x < 0 else "")
|
|
131
|
+
return f"{sign}${s}"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _cost_finding(res: dict, a: str, b: str) -> Finding:
|
|
135
|
+
ms = res["months"]
|
|
136
|
+
if not ms:
|
|
137
|
+
return Finding("cost.mom", "info", f"No DBU usage found for {a} to {b}",
|
|
138
|
+
"System tables have no rows for these months in this workspace. They may not have been enabled yet.", [], "")
|
|
139
|
+
lines = []
|
|
140
|
+
for m in ms:
|
|
141
|
+
rate = f"{_usd(m['rate'])}/DBU" if m["rate"] else "n/a"
|
|
142
|
+
lines.append(f"{m['month']}: {m['dbus']:,.0f} DBU, {_usd(m['cost'])} ({m['days']} days of data{', partial month' if m['partial'] else ''}), "
|
|
143
|
+
f"{_usd(m['cost_per_day'])}/day, {rate}, tier {m['dominant_tier']}")
|
|
144
|
+
last = ms[-1].get("vs_first")
|
|
145
|
+
sev, extra = "info", ""
|
|
146
|
+
if last and last["pct"] is not None:
|
|
147
|
+
extra = (f" Against {ms[0]['month']}, cost per day moved {last['pct']:+.0f}% ({_usd(last['cost_per_day_change'], True)}/day): "
|
|
148
|
+
f"{_usd(last['volume_effect_per_day'], True)}/day from usage (volume and SKU mix), "
|
|
149
|
+
f"{_usd(last['price_effect_per_day'], True)}/day from list price per DBU.")
|
|
150
|
+
if abs(last["pct"]) >= 20:
|
|
151
|
+
sev = "medium"
|
|
152
|
+
if ms[0]["days"] < 15 and len(ms) > 1:
|
|
153
|
+
extra += f" Weak baseline: {ms[0]['month']} has only {ms[0]['days']} days of data, so the percentage change overstates the trend."
|
|
154
|
+
if res["tier_changed"]:
|
|
155
|
+
extra += " The SKU tier changed during this period: " + " then ".join(f"{m}={t}" for m, t in res["tier_sequence"]) + "."
|
|
156
|
+
return Finding("cost.mom", sev, f"DBU spend {a} to {b}", "; ".join(lines) + "." + extra, [], "")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
SEVERITIES = ("high", "medium", "low", "info")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Finding:
|
|
10
|
+
id: str
|
|
11
|
+
severity: str
|
|
12
|
+
title: str
|
|
13
|
+
detail: str
|
|
14
|
+
items: list[dict] = field(default_factory=list)
|
|
15
|
+
fix: str = ""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Report:
|
|
20
|
+
meta: dict
|
|
21
|
+
findings: list[Finding] = field(default_factory=list)
|
|
22
|
+
cost: dict | None = None
|
|
23
|
+
not_collected: list[dict] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
def counts(self) -> dict[str, int]:
|
|
26
|
+
c = {s: 0 for s in SEVERITIES}
|
|
27
|
+
for f in self.findings:
|
|
28
|
+
c[f.severity] += 1
|
|
29
|
+
return c
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""All SQL the health check sends. Each statement starts with a marker comment, which also makes the tool's
|
|
2
|
+
queries easy to find in query history. Values that go into SQL are validated here, never passed through raw."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from datetime import date
|
|
8
|
+
|
|
9
|
+
_ID = re.compile(r"^\d+$")
|
|
10
|
+
|
|
11
|
+
# DBU list price in force when the usage record ended. List price: before any negotiated discount.
|
|
12
|
+
_PRICED = """
|
|
13
|
+
FROM system.billing.usage u
|
|
14
|
+
LEFT JOIN system.billing.list_prices p
|
|
15
|
+
ON p.sku_name = u.sku_name AND p.usage_unit = u.usage_unit
|
|
16
|
+
AND u.usage_end_time >= p.price_start_time
|
|
17
|
+
AND (p.price_end_time IS NULL OR u.usage_end_time < p.price_end_time)
|
|
18
|
+
"""
|
|
19
|
+
_COST = "u.usage_quantity * p.pricing.effective_list.default"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _ws(workspace_id: str | None) -> str:
|
|
23
|
+
if workspace_id is None:
|
|
24
|
+
return ""
|
|
25
|
+
if not _ID.match(workspace_id):
|
|
26
|
+
raise ValueError("workspace id must be numeric")
|
|
27
|
+
return f"AND u.workspace_id = '{workspace_id}'"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def cost_by_month(start: date, end_exclusive: date, workspace_id: str | None) -> str:
|
|
31
|
+
return f"""/* dbagent:cost_by_month */
|
|
32
|
+
SELECT date_trunc('month', u.usage_date) AS month, u.sku_name,
|
|
33
|
+
sum(u.usage_quantity) AS dbus, sum({_COST}) AS cost, count(DISTINCT u.usage_date) AS days
|
|
34
|
+
{_PRICED}
|
|
35
|
+
WHERE u.usage_date >= DATE'{start.isoformat()}' AND u.usage_date < DATE'{end_exclusive.isoformat()}'
|
|
36
|
+
AND u.usage_unit = 'DBU' {_ws(workspace_id)}
|
|
37
|
+
GROUP BY 1, 2 ORDER BY 1, 2"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def spend_by_cluster(days: int, workspace_id: str | None) -> str:
|
|
41
|
+
return f"""/* dbagent:spend_by_cluster */
|
|
42
|
+
SELECT u.usage_metadata.cluster_id AS cluster_id, sum(u.usage_quantity) AS dbus, sum({_COST}) AS cost
|
|
43
|
+
{_PRICED}
|
|
44
|
+
WHERE u.usage_date >= date_sub(current_date(), {int(days)}) AND u.usage_metadata.cluster_id IS NOT NULL
|
|
45
|
+
AND u.billing_origin_product IN ('ALL_PURPOSE', 'INTERACTIVE') AND u.usage_unit = 'DBU' {_ws(workspace_id)}
|
|
46
|
+
GROUP BY 1"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def spend_by_warehouse(days: int, workspace_id: str | None) -> str:
|
|
50
|
+
return f"""/* dbagent:spend_by_warehouse */
|
|
51
|
+
SELECT u.usage_metadata.warehouse_id AS warehouse_id, sum(u.usage_quantity) AS dbus, sum({_COST}) AS cost
|
|
52
|
+
{_PRICED}
|
|
53
|
+
WHERE u.usage_date >= date_sub(current_date(), {int(days)}) AND u.usage_metadata.warehouse_id IS NOT NULL
|
|
54
|
+
AND u.usage_unit = 'DBU' {_ws(workspace_id)}
|
|
55
|
+
GROUP BY 1"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def queries_by_warehouse(days: int, workspace_id: str | None) -> str:
|
|
59
|
+
w = f"AND workspace_id = '{workspace_id}'" if workspace_id else ""
|
|
60
|
+
if workspace_id and not _ID.match(workspace_id):
|
|
61
|
+
raise ValueError("workspace id must be numeric")
|
|
62
|
+
return f"""/* dbagent:queries_by_warehouse */
|
|
63
|
+
SELECT compute.warehouse_id AS warehouse_id, count(*) AS queries, max(start_time) AS last_query
|
|
64
|
+
FROM system.query.history
|
|
65
|
+
WHERE start_time >= current_timestamp() - INTERVAL {int(days)} DAYS AND compute.type = 'WAREHOUSE' {w}
|
|
66
|
+
GROUP BY 1"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def untagged_spend(days: int, workspace_id: str | None) -> str:
|
|
70
|
+
return f"""/* dbagent:untagged_spend */
|
|
71
|
+
SELECT u.billing_origin_product AS product,
|
|
72
|
+
sum({_COST}) AS cost,
|
|
73
|
+
sum(CASE WHEN u.custom_tags IS NULL OR cardinality(u.custom_tags) = 0 THEN {_COST} END) AS untagged_cost,
|
|
74
|
+
sum(u.usage_quantity) AS dbus,
|
|
75
|
+
sum(CASE WHEN u.custom_tags IS NULL OR cardinality(u.custom_tags) = 0 THEN u.usage_quantity END) AS untagged_dbus
|
|
76
|
+
{_PRICED}
|
|
77
|
+
WHERE u.usage_date >= date_sub(current_date(), {int(days)}) AND u.usage_unit = 'DBU' {_ws(workspace_id)}
|
|
78
|
+
GROUP BY 1 ORDER BY 3 DESC"""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def dbfs_mount_events(days: int, workspace_id: str | None) -> str:
|
|
82
|
+
w = f"AND workspace_id = '{workspace_id}'" if workspace_id else ""
|
|
83
|
+
if workspace_id and not _ID.match(workspace_id):
|
|
84
|
+
raise ValueError("workspace id must be numeric")
|
|
85
|
+
return f"""/* dbagent:dbfs_mount_events */
|
|
86
|
+
SELECT event_date, user_identity.email AS user_email, to_json(request_params) AS params
|
|
87
|
+
FROM system.access.audit
|
|
88
|
+
WHERE service_name = 'dbfs' AND action_name = 'mount' AND event_date >= date_sub(current_date(), {int(days)}) {w}
|
|
89
|
+
ORDER BY event_date DESC LIMIT 500"""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
SHOW_SCHEMAS = "/* dbagent:hms_schemas */ SHOW SCHEMAS IN hive_metastore"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def show_tables(schema: str) -> str:
|
|
96
|
+
if not re.match(r"^[\w\-]+$", schema):
|
|
97
|
+
raise ValueError(f"odd schema name {schema!r}")
|
|
98
|
+
return f"/* dbagent:hms_tables */ SHOW TABLES IN hive_metastore.`{schema}`"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .model import Report
|
|
7
|
+
|
|
8
|
+
_GROUPS = [("runtime.", "Runtimes"), ("legacy.", "Legacy dependencies"), ("cost.", "Cost over time"), ("waste.", "Spend waste")]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def to_json(rep: Report) -> str:
|
|
12
|
+
return json.dumps({"meta": rep.meta, "counts": rep.counts(), "cost": rep.cost,
|
|
13
|
+
"findings": [f.__dict__ for f in rep.findings], "not_collected": rep.not_collected},
|
|
14
|
+
indent=2, default=str)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _table(items: list[dict], limit: int = 15) -> list[str]:
|
|
18
|
+
if not items:
|
|
19
|
+
return []
|
|
20
|
+
cols = list(items[0].keys())
|
|
21
|
+
out = ["", "| " + " | ".join(cols) + " |", "|" + "---|" * len(cols)]
|
|
22
|
+
for r in items[:limit]:
|
|
23
|
+
out.append("| " + " | ".join(str(r.get(c, "")).replace("|", "/") for c in cols) + " |")
|
|
24
|
+
if len(items) > limit:
|
|
25
|
+
out.append(f"\n{len(items) - limit} more in the JSON file.")
|
|
26
|
+
return out
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def to_markdown(rep: Report) -> str:
|
|
30
|
+
c = rep.counts()
|
|
31
|
+
m = rep.meta
|
|
32
|
+
lines = ["# Databricks workspace health", "",
|
|
33
|
+
f"Workspace {m.get('workspace_id')} at {m.get('host')}. Generated {m.get('generated_utc')} by {m.get('tool')}. "
|
|
34
|
+
f"Read-only. Spend figures are list price. Lookback {m.get('lookback_days')} days.", "",
|
|
35
|
+
f"**{c['high']} high, {c['medium']} medium, {c['low']} low, {c['info']} info.**", ""]
|
|
36
|
+
for prefix, title in _GROUPS:
|
|
37
|
+
fs = [f for f in rep.findings if f.id.startswith(prefix)]
|
|
38
|
+
if not fs:
|
|
39
|
+
continue
|
|
40
|
+
lines += [f"## {title}", ""]
|
|
41
|
+
for f in fs:
|
|
42
|
+
lines += [f"### [{f.severity.upper()}] {f.title}", "", f.detail]
|
|
43
|
+
if f.fix:
|
|
44
|
+
lines += ["", f"Fix: {f.fix}"]
|
|
45
|
+
lines += _table(f.items)
|
|
46
|
+
lines.append("")
|
|
47
|
+
if rep.cost and rep.cost["months"]:
|
|
48
|
+
lines += ["### Monthly figures", "", "| month | DBU | list $ | days | $/day | $/DBU | tier |", "|---|---|---|---|---|---|---|"]
|
|
49
|
+
for x in rep.cost["months"]:
|
|
50
|
+
lines.append(f"| {x['month']}{' (partial)' if x['partial'] else ''} | {x['dbus']:,.0f} | {x['cost']:,.0f} | {x['days']} | "
|
|
51
|
+
f"{x['cost_per_day']:,.0f} | {(('%.3f' % x['rate']) if x['rate'] else 'n/a')} | {x['dominant_tier']} |")
|
|
52
|
+
lines.append("")
|
|
53
|
+
if rep.not_collected:
|
|
54
|
+
lines += ["## Not collected", "", "These checks did not run. Results above do not cover them.", ""]
|
|
55
|
+
lines += [f"- {n['check']}: {n['reason']}" for n in rep.not_collected]
|
|
56
|
+
return "\n".join(lines) + "\n"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def write(rep: Report, out: Path, formats: set[str]) -> list[Path]:
|
|
60
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
paths = []
|
|
62
|
+
if "json" in formats:
|
|
63
|
+
paths.append(out / "databricks-health.json")
|
|
64
|
+
paths[-1].write_text(to_json(rep))
|
|
65
|
+
if "md" in formats:
|
|
66
|
+
paths.append(out / "databricks-health.md")
|
|
67
|
+
paths[-1].write_text(to_markdown(rep))
|
|
68
|
+
return paths
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def console(rep: Report) -> str:
|
|
72
|
+
c = rep.counts()
|
|
73
|
+
lines = [f"Databricks health: {c['high']} high, {c['medium']} medium, {c['low']} low, {c['info']} info", ""]
|
|
74
|
+
for f in rep.findings:
|
|
75
|
+
lines.append(f" [{f.severity.upper():<6}] {f.title}")
|
|
76
|
+
if rep.not_collected:
|
|
77
|
+
lines += ["", f"Not collected ({len(rep.not_collected)}):"] + [f" - {n['check']}: {n['reason'][:110]}" for n in rep.not_collected]
|
|
78
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Databricks Runtime support dates.
|
|
2
|
+
|
|
3
|
+
Source: https://docs.databricks.com/aws/en/release-notes/runtime/eos (ended versions) and
|
|
4
|
+
https://docs.databricks.com/aws/en/release-notes/runtime/ (supported versions), read on 2026-11-07.
|
|
5
|
+
Update EOS when Databricks publishes new versions. Versions newer than the newest one listed here are
|
|
6
|
+
reported as "unknown", never as supported.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import date
|
|
14
|
+
|
|
15
|
+
EOS: dict[str, date] = {
|
|
16
|
+
"10.5": date(2022, 11, 4), "11.0": date(2022, 12, 16), "11.1": date(2023, 1, 27), "11.2": date(2023, 3, 7),
|
|
17
|
+
"11.3": date(2025, 10, 19), "12.0": date(2023, 6, 15), "12.1": date(2023, 7, 18), "12.2": date(2026, 3, 1),
|
|
18
|
+
"13.0": date(2023, 10, 14), "13.1": date(2023, 12, 1), "13.2": date(2024, 1, 6), "13.3": date(2026, 8, 22),
|
|
19
|
+
"14.0": date(2024, 3, 11), "14.1": date(2025, 2, 12), "14.2": date(2024, 11, 5), "14.3": date(2027, 2, 1),
|
|
20
|
+
"15.0": date(2024, 5, 31), "15.1": date(2024, 10, 30), "15.2": date(2025, 1, 7), "15.3": date(2025, 1, 7),
|
|
21
|
+
"15.4": date(2027, 8, 19), "16.0": date(2025, 5, 11), "16.1": date(2025, 6, 20), "16.2": date(2025, 8, 5),
|
|
22
|
+
"16.3": date(2025, 9, 30), "16.4": date(2028, 5, 9), "17.0": date(2025, 12, 23), "17.1": date(2026, 2, 1),
|
|
23
|
+
"17.2": date(2026, 3, 16), "17.3": date(2028, 10, 22), "18.0": date(2026, 7, 15), "18.1": date(2026, 9, 11),
|
|
24
|
+
"18.2": date(2026, 11, 5),
|
|
25
|
+
}
|
|
26
|
+
LTS = {"11.3", "12.2", "13.3", "14.3", "15.4", "16.4", "17.3"}
|
|
27
|
+
NEWEST_KNOWN = (18, 2)
|
|
28
|
+
SOURCE = "https://docs.databricks.com/aws/en/release-notes/runtime/eos"
|
|
29
|
+
|
|
30
|
+
_VER = re.compile(r"^(\d+)\.(\d+)")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class RuntimeStatus:
|
|
35
|
+
spark_version: str
|
|
36
|
+
label: str # "13.3 LTS"
|
|
37
|
+
eos: date | None
|
|
38
|
+
state: str # ended | ending_soon | supported | unknown
|
|
39
|
+
days_left: int | None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def classify(spark_version: str | None, today: date, warn_days: int = 180) -> RuntimeStatus:
|
|
43
|
+
sv = spark_version or ""
|
|
44
|
+
if sv.startswith("apache-spark-"):
|
|
45
|
+
return RuntimeStatus(sv, sv, None, "ended", None) # Spark 2.x / 3.0 era images
|
|
46
|
+
m = _VER.match(sv)
|
|
47
|
+
if not m:
|
|
48
|
+
return RuntimeStatus(sv, sv or "unknown", None, "unknown", None) # custom images, empty
|
|
49
|
+
key = f"{int(m.group(1))}.{int(m.group(2))}"
|
|
50
|
+
label = key + (" LTS" if key in LTS else "")
|
|
51
|
+
eos = EOS.get(key)
|
|
52
|
+
if eos is None:
|
|
53
|
+
if (int(m.group(1)), int(m.group(2))) < (10, 5):
|
|
54
|
+
return RuntimeStatus(sv, label, None, "ended", None) # 10.4 LTS and older are end-of-life
|
|
55
|
+
return RuntimeStatus(sv, label, None, "unknown", None) # newer than this table: check the docs
|
|
56
|
+
left = (eos - today).days
|
|
57
|
+
state = "ended" if left < 0 else "ending_soon" if left <= warn_days else "supported"
|
|
58
|
+
return RuntimeStatus(sv, label, eos, state, left)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Every statement sent to a SQL warehouse goes through here first.
|
|
2
|
+
|
|
3
|
+
Allowed: one SELECT/WITH statement, SHOW SCHEMAS IN hive_metastore, SHOW TABLES IN hive_metastore.<schema>.
|
|
4
|
+
Anything else is refused, including statements that mention a write keyword anywhere outside literals.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GuardError(Exception):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_FORBIDDEN = re.compile(
|
|
17
|
+
r"\b(insert|update|delete|merge|drop|create|alter|truncate|grant|revoke|deny|copy|optimize|vacuum|call|set|use|put|"
|
|
18
|
+
r"msck|repair|refresh|analyze|cache|uncache|load|restore|undrop|overwrite|into|exec|execute)\b", re.I)
|
|
19
|
+
_SHOW = re.compile(r"^show\s+(schemas\s+in\s+hive_metastore|tables\s+in\s+hive_metastore\.`?[\w\-]+`?)$", re.I)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _strip(sql: str) -> str:
|
|
23
|
+
"""Remove comments, string literals and quoted identifiers so keywords inside them do not count."""
|
|
24
|
+
out, i, n = [], 0, len(sql)
|
|
25
|
+
while i < n:
|
|
26
|
+
c = sql[i]
|
|
27
|
+
if sql.startswith("--", i):
|
|
28
|
+
j = sql.find("\n", i)
|
|
29
|
+
i = n if j < 0 else j
|
|
30
|
+
elif sql.startswith("/*", i):
|
|
31
|
+
j = sql.find("*/", i + 2)
|
|
32
|
+
if j < 0:
|
|
33
|
+
raise GuardError("unterminated comment")
|
|
34
|
+
i = j + 2
|
|
35
|
+
elif c in ("'", '"', "`"):
|
|
36
|
+
j = i + 1
|
|
37
|
+
while j < n:
|
|
38
|
+
if sql[j] == "\\" and c != "`":
|
|
39
|
+
j += 2
|
|
40
|
+
continue
|
|
41
|
+
if sql[j] == c:
|
|
42
|
+
if j + 1 < n and sql[j + 1] == c: # doubled quote
|
|
43
|
+
j += 2
|
|
44
|
+
continue
|
|
45
|
+
break
|
|
46
|
+
j += 1
|
|
47
|
+
else:
|
|
48
|
+
raise GuardError("unterminated quote")
|
|
49
|
+
out.append(" ")
|
|
50
|
+
if c == "`":
|
|
51
|
+
out[-1] = "x" # keep backtick identifiers non-empty, so SHOW ... `schema` still matches
|
|
52
|
+
i = j + 1
|
|
53
|
+
else:
|
|
54
|
+
out.append(c)
|
|
55
|
+
i += 1
|
|
56
|
+
return "".join(out)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def check(sql: str) -> None:
|
|
60
|
+
body = _strip(sql).strip().rstrip(";").strip()
|
|
61
|
+
if not body:
|
|
62
|
+
raise GuardError("empty statement")
|
|
63
|
+
if ";" in body:
|
|
64
|
+
raise GuardError("only one statement is allowed")
|
|
65
|
+
flat = re.sub(r"\s+", " ", body)
|
|
66
|
+
if _SHOW.match(sql_without_comments(sql)):
|
|
67
|
+
return
|
|
68
|
+
first = flat.split(" ", 1)[0].lower()
|
|
69
|
+
if first not in ("select", "with"):
|
|
70
|
+
raise GuardError(f"only SELECT/WITH and SHOW ... IN hive_metastore are allowed, got {first.upper()}")
|
|
71
|
+
bad = _FORBIDDEN.search(flat)
|
|
72
|
+
if bad:
|
|
73
|
+
raise GuardError(f"refused: {bad.group(0).upper()} is not allowed")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def sql_without_comments(sql: str) -> str:
|
|
77
|
+
"""Comments removed but literals and identifiers kept, for the exact SHOW forms."""
|
|
78
|
+
s = re.sub(r"/\*.*?\*/", " ", sql, flags=re.S)
|
|
79
|
+
s = re.sub(r"--[^\n]*", " ", s)
|
|
80
|
+
return re.sub(r"\s+", " ", s).strip().rstrip(";").strip()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""db-agent: read-only inventory of SQL Server and SSRS reports."""
|
|
2
|
+
|
|
3
|
+
from db_agent import __version__ # noqa: F401 (one version for the whole CLI)
|
|
4
|
+
|
|
5
|
+
SUPPORTED_SQL_SERVER = "SQL Server 2016 to 2022 (read), Azure SQL Database and Managed Instance (read)"
|
|
6
|
+
|
|
7
|
+
# Verified against Microsoft's lifecycle pages on 2026-11-05.
|
|
8
|
+
END_OF_SUPPORT = {
|
|
9
|
+
13: ("SQL Server 2016", "2026-07-14"),
|
|
10
|
+
14: ("SQL Server 2017", "2027-10-12"),
|
|
11
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Classify each report as move, review or retire, with the reasons."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import resources
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from .rdl import layout_hint, non_sql_sources
|
|
12
|
+
from .usage import ReportUsage, UsageData
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_config(path: Path | None = None) -> dict[str, Any]:
|
|
16
|
+
default = yaml.safe_load(resources.files("db_agent.engines.sqlserver").joinpath("data/classification.yaml").read_text())
|
|
17
|
+
if path:
|
|
18
|
+
user = yaml.safe_load(Path(path).read_text()) or {}
|
|
19
|
+
review = {**default.get("review_if", {}), **user.get("review_if", {})}
|
|
20
|
+
default.update(user)
|
|
21
|
+
default["review_if"] = review
|
|
22
|
+
return default
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def classify(
|
|
26
|
+
report: Any,
|
|
27
|
+
usage: UsageData | None,
|
|
28
|
+
cfg: dict[str, Any],
|
|
29
|
+
subscriptions: dict[str, Any] | None = None,
|
|
30
|
+
unresolved_shared: bool = False,
|
|
31
|
+
sql_parse_failed: list[str] | None = None,
|
|
32
|
+
) -> dict[str, Any]:
|
|
33
|
+
"""Return {classification, reasons, usage_known, layout_hint, ...} for one parsed report."""
|
|
34
|
+
reasons: list[str] = []
|
|
35
|
+
rv = cfg["review_if"]
|
|
36
|
+
u: ReportUsage | None = None
|
|
37
|
+
usage_known = usage is not None and usage.source in ("csv", "executionlog3")
|
|
38
|
+
if usage_known:
|
|
39
|
+
u = usage.get(report.path)
|
|
40
|
+
|
|
41
|
+
# 1. Retire, only when the log is long enough to prove it.
|
|
42
|
+
if usage_known and u is not None and u.runs_365 == 0:
|
|
43
|
+
if usage.window_days >= cfg["min_log_window_days"]:
|
|
44
|
+
return _result(report, usage, u, "retire", [f"No runs in the last {cfg['retire_after_days']} days (log covers {usage.window_days} days)"])
|
|
45
|
+
reasons.append(
|
|
46
|
+
f"No runs in the log, but the log covers only {usage.window_days} days, so it cannot show the report is unused. "
|
|
47
|
+
f"Raise ExecutionLogDaysKept or supply a longer log"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# 2. Review reasons.
|
|
51
|
+
if report.parse_error and rv.get("parse_failure", True):
|
|
52
|
+
reasons.append(f"Report definition could not be parsed ({report.parse_error})")
|
|
53
|
+
if sql_parse_failed and rv.get("parse_failure", True):
|
|
54
|
+
reasons.append(f"Dataset SQL could not be fully parsed ({', '.join(sql_parse_failed)}); table list is a guess")
|
|
55
|
+
if report.has_custom_code and rv.get("custom_code", True):
|
|
56
|
+
reasons.append(f"Custom code block ({report.custom_code_lines} lines)")
|
|
57
|
+
if report.custom_assemblies and rv.get("custom_assemblies", True):
|
|
58
|
+
reasons.append("Uses custom assemblies or classes")
|
|
59
|
+
if report.subreports and rv.get("subreports", True):
|
|
60
|
+
reasons.append(f"Subreports: {', '.join(report.subreports)}")
|
|
61
|
+
if report.drillthrough and rv.get("drillthrough", True):
|
|
62
|
+
reasons.append(f"Drillthrough to: {', '.join(sorted(set(report.drillthrough)))}")
|
|
63
|
+
subs = (subscriptions or {}).get(report.path.lower())
|
|
64
|
+
if subs and rv.get("complex_subscription", True) and (subs.get("data_driven") or subs.get("non_email")):
|
|
65
|
+
kinds = [k for k, v in (("data-driven", subs.get("data_driven")), ("non-email delivery", subs.get("non_email"))) if v]
|
|
66
|
+
reasons.append(f"Subscriptions: {subs['count']} ({', '.join(kinds)})")
|
|
67
|
+
ns = non_sql_sources(report)
|
|
68
|
+
if ns and rv.get("non_sql_data_source", True):
|
|
69
|
+
reasons.append(f"Non-SQL data source: {', '.join(ns)}")
|
|
70
|
+
if unresolved_shared and rv.get("unresolved_shared_dataset", True):
|
|
71
|
+
reasons.append("Uses a shared dataset or data source that was not supplied")
|
|
72
|
+
if report.expressions > cfg["complex_expression_threshold"]:
|
|
73
|
+
reasons.append(f"{report.expressions} expressions (threshold {cfg['complex_expression_threshold']})")
|
|
74
|
+
if rv.get("stored_procedure_dataset", False) and any(d.command_type == "StoredProcedure" for d in report.datasets):
|
|
75
|
+
reasons.append("Dataset calls a stored procedure")
|
|
76
|
+
if usage_known and u is not None and 0 < u.runs_365 < cfg["low_usage_runs_365"]:
|
|
77
|
+
reasons.append(f"Low usage: {u.runs_365} runs in 365 days")
|
|
78
|
+
if usage_known and u is not None and u.runs_365 and u.errors / max(u.runs_365, 1) > cfg["error_rate_review"]:
|
|
79
|
+
reasons.append(f"{u.errors} of {u.runs_365} runs failed")
|
|
80
|
+
|
|
81
|
+
label = "review" if reasons else "move"
|
|
82
|
+
if label == "move":
|
|
83
|
+
reasons = ["Plain report, no hard-to-move features" + ("" if usage_known else "; usage unknown")]
|
|
84
|
+
return _result(report, usage, u, label, reasons, subs)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _result(report: Any, usage: UsageData | None, u: ReportUsage | None, label: str, reasons: list[str], subs: dict | None = None) -> dict[str, Any]:
|
|
88
|
+
usage_known = usage is not None
|
|
89
|
+
return {
|
|
90
|
+
"classification": label,
|
|
91
|
+
"reasons": reasons,
|
|
92
|
+
"usage_known": usage_known,
|
|
93
|
+
"log_window_days": usage.window_days if usage else None,
|
|
94
|
+
"layout_hint": layout_hint(report),
|
|
95
|
+
"last_run": u.last_run.strftime("%Y-%m-%d") if (u and u.last_run) else None,
|
|
96
|
+
"runs_90": u.runs_90 if u else None,
|
|
97
|
+
"runs_365": u.runs_365 if u else None,
|
|
98
|
+
"distinct_users": u.distinct_users if u else None,
|
|
99
|
+
"avg_ms": u.avg_ms if u else None,
|
|
100
|
+
"errors": u.errors if u else None,
|
|
101
|
+
"subscriptions": (subs or {}).get("count", 0),
|
|
102
|
+
}
|