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.
Files changed (36) hide show
  1. db_agent/__init__.py +3 -0
  2. db_agent/cli.py +32 -0
  3. db_agent/engines/__init__.py +36 -0
  4. db_agent/engines/databricks/__init__.py +1 -0
  5. db_agent/engines/databricks/checks.py +221 -0
  6. db_agent/engines/databricks/cli.py +61 -0
  7. db_agent/engines/databricks/client.py +86 -0
  8. db_agent/engines/databricks/cost.py +96 -0
  9. db_agent/engines/databricks/health.py +156 -0
  10. db_agent/engines/databricks/model.py +29 -0
  11. db_agent/engines/databricks/queries.py +98 -0
  12. db_agent/engines/databricks/render.py +78 -0
  13. db_agent/engines/databricks/runtimes.py +58 -0
  14. db_agent/engines/databricks/sqlguard.py +80 -0
  15. db_agent/engines/sqlserver/__init__.py +11 -0
  16. db_agent/engines/sqlserver/classify.py +102 -0
  17. db_agent/engines/sqlserver/cli.py +265 -0
  18. db_agent/engines/sqlserver/data/classification.yaml +29 -0
  19. db_agent/engines/sqlserver/db.py +183 -0
  20. db_agent/engines/sqlserver/guard.py +190 -0
  21. db_agent/engines/sqlserver/inventory.py +324 -0
  22. db_agent/engines/sqlserver/outputs.py +299 -0
  23. db_agent/engines/sqlserver/rdl.py +269 -0
  24. db_agent/engines/sqlserver/report.py +120 -0
  25. db_agent/engines/sqlserver/risks.py +184 -0
  26. db_agent/engines/sqlserver/rules.py +141 -0
  27. db_agent/engines/sqlserver/sqlrefs.py +103 -0
  28. db_agent/engines/sqlserver/ssrs.py +230 -0
  29. db_agent/engines/sqlserver/templates/summary.html.j2 +142 -0
  30. db_agent/engines/sqlserver/usage.py +127 -0
  31. db_agent/engines/sqlserver/validate.py +238 -0
  32. db_agent_kit-0.2.0.dist-info/METADATA +192 -0
  33. db_agent_kit-0.2.0.dist-info/RECORD +36 -0
  34. db_agent_kit-0.2.0.dist-info/WHEEL +4 -0
  35. db_agent_kit-0.2.0.dist-info/entry_points.txt +2 -0
  36. 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
+ }