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,269 @@
1
+ """Parse SSRS report definitions (RDL 2008, 2010, 2016) and shared datasets/data sources.
2
+
3
+ Namespace-agnostic: element names are compared without the namespace, so one code path handles
4
+ every schema version. Connection strings are reduced to server and database names; passwords
5
+ are never kept (only a flag that one was present).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from lxml import etree
16
+
17
+ RDL_NAMESPACES = {
18
+ "http://schemas.microsoft.com/sqlserver/reporting/2016/01/reportdefinition": "2016",
19
+ "http://schemas.microsoft.com/sqlserver/reporting/2010/01/reportdefinition": "2010",
20
+ "http://schemas.microsoft.com/sqlserver/reporting/2008/01/reportdefinition": "2008",
21
+ "http://schemas.microsoft.com/sqlserver/reporting/2005/01/reportdefinition": "2005",
22
+ }
23
+ INTERACTIVE_ELEMENTS = {"Chart", "GaugePanel", "Map", "Indicator", "Sparkline", "DataBar", "CustomReportItem"}
24
+ LAYOUT_ELEMENTS = {"Tablix", "Textbox", "Image", "Rectangle", "Line", "Subreport"} | INTERACTIVE_ELEMENTS
25
+ NON_SQL_PROVIDERS = {"OLEDB-MD", "SSAS", "ESSBASE", "SAPBW", "XML", "SHAREPOINTLIST", "ODATA", "AZUREBLOB"}
26
+ LOOKUP_RE = re.compile(r"\b(Lookup|LookupSet|MultiLookup)\s*\(", re.IGNORECASE)
27
+ CODE_CALL_RE = re.compile(r"\bCode\.\w+", re.IGNORECASE)
28
+
29
+
30
+ @dataclass
31
+ class DataSource:
32
+ name: str
33
+ shared_reference: str | None = None
34
+ provider: str | None = None
35
+ server: str | None = None
36
+ database: str | None = None
37
+ integrated_security: bool | None = None
38
+ embedded_credentials: bool = False
39
+
40
+
41
+ @dataclass
42
+ class DataSet:
43
+ name: str
44
+ data_source: str | None = None
45
+ command_type: str = "Text" # Text | StoredProcedure | TableDirect
46
+ command_text: str = ""
47
+ query_parameters: list[dict[str, str]] = field(default_factory=list)
48
+ shared_dataset_reference: str | None = None
49
+ field_count: int = 0
50
+
51
+
52
+ @dataclass
53
+ class Report:
54
+ file: str
55
+ name: str
56
+ folder: str
57
+ path: str # /Folder/Name, the form SSRS uses
58
+ rdl_version: str = "unknown"
59
+ data_sources: list[DataSource] = field(default_factory=list)
60
+ datasets: list[DataSet] = field(default_factory=list)
61
+ parameters: list[dict[str, Any]] = field(default_factory=list)
62
+ subreports: list[str] = field(default_factory=list)
63
+ drillthrough: list[str] = field(default_factory=list)
64
+ has_custom_code: bool = False
65
+ custom_code_lines: int = 0
66
+ custom_assemblies: bool = False
67
+ embedded_images: int = 0
68
+ expressions: int = 0
69
+ complex_expressions: int = 0
70
+ layout: dict[str, int] = field(default_factory=dict)
71
+ has_page_header_footer: bool = False
72
+ toggles: int = 0
73
+ parse_error: str | None = None
74
+
75
+
76
+ def _local(el: etree._Element) -> str:
77
+ return etree.QName(el).localname if isinstance(el.tag, str) else ""
78
+
79
+
80
+ def _children(el: etree._Element, name: str) -> list[etree._Element]:
81
+ return [c for c in el if _local(c) == name]
82
+
83
+
84
+ def _child(el: etree._Element, name: str) -> etree._Element | None:
85
+ for c in el:
86
+ if _local(c) == name:
87
+ return c
88
+ return None
89
+
90
+
91
+ def _text(el: etree._Element | None, name: str | None = None) -> str | None:
92
+ node = _child(el, name) if (el is not None and name) else el
93
+ if node is None or node.text is None:
94
+ return None
95
+ return node.text.strip()
96
+
97
+
98
+ def _iter(el: etree._Element, name: str):
99
+ for n in el.iter():
100
+ if _local(n) == name:
101
+ yield n
102
+
103
+
104
+ def _parse_connect_string(cs: str | None) -> tuple[str | None, str | None, bool | None, bool]:
105
+ """Return (server, database, integrated_security, had_password). The string is not kept."""
106
+ if not cs or cs.strip().startswith("="):
107
+ return None, None, None, False # an expression: cannot be resolved statically
108
+ pairs = {k.strip().lower(): v.strip() for k, _, v in (p.partition("=") for p in cs.split(";")) if k.strip()}
109
+ server = pairs.get("data source") or pairs.get("server") or pairs.get("address") or pairs.get("addr")
110
+ database = pairs.get("initial catalog") or pairs.get("database")
111
+ integ = pairs.get("integrated security") or pairs.get("trusted_connection")
112
+ integrated = None if integ is None else integ.lower() in ("true", "yes", "sspi")
113
+ had_pw = any(k in pairs for k in ("password", "pwd")) and bool(pairs.get("password") or pairs.get("pwd"))
114
+ return server, database, integrated, had_pw
115
+
116
+
117
+ def _parse_datasources(root: etree._Element) -> list[DataSource]:
118
+ out: list[DataSource] = []
119
+ holder = _child(root, "DataSources")
120
+ for ds in _children(holder, "DataSource") if holder is not None else []:
121
+ item = DataSource(name=ds.get("Name", ""))
122
+ item.shared_reference = _text(ds, "DataSourceReference")
123
+ conn = _child(ds, "ConnectionProperties")
124
+ if conn is not None:
125
+ item.provider = _text(conn, "DataProvider")
126
+ item.server, item.database, item.integrated_security, item.embedded_credentials = _parse_connect_string(
127
+ _text(conn, "ConnectString")
128
+ )
129
+ if _text(conn, "IntegratedSecurity"):
130
+ item.integrated_security = _text(conn, "IntegratedSecurity").lower() == "true"
131
+ out.append(item)
132
+ return out
133
+
134
+
135
+ def _parse_dataset(ds: etree._Element) -> DataSet:
136
+ item = DataSet(name=ds.get("Name", ""))
137
+ q = _child(ds, "Query")
138
+ if q is not None:
139
+ item.data_source = _text(q, "DataSourceName")
140
+ item.command_type = _text(q, "CommandType") or "Text"
141
+ item.command_text = _text(q, "CommandText") or ""
142
+ qp = _child(q, "QueryParameters")
143
+ for p in _children(qp, "QueryParameter") if qp is not None else []:
144
+ item.query_parameters.append({"name": p.get("Name", ""), "value": _text(p, "Value") or ""})
145
+ shared = _child(ds, "SharedDataSet")
146
+ if shared is not None:
147
+ item.shared_dataset_reference = _text(shared, "SharedDataSetReference")
148
+ fields = _child(ds, "Fields")
149
+ item.field_count = len(_children(fields, "Field")) if fields is not None else 0
150
+ return item
151
+
152
+
153
+ def _count_expressions(root: etree._Element) -> tuple[int, int]:
154
+ total = complex_ = 0
155
+ for el in root.iter():
156
+ if not isinstance(el.tag, str) or el.text is None:
157
+ continue
158
+ t = el.text.strip()
159
+ if t.startswith("=") and _local(el) not in ("CommandText", "Code"):
160
+ total += 1
161
+ if CODE_CALL_RE.search(t) or LOOKUP_RE.search(t):
162
+ complex_ += 1
163
+ return total, complex_
164
+
165
+
166
+ def parse_rdl_bytes(data: bytes, file: str, base: Path | None = None) -> Report:
167
+ p = Path(file)
168
+ rel = p.relative_to(base) if base and p.is_absolute() and str(p).startswith(str(base)) else p
169
+ folder = "/" + "/".join(rel.parent.parts) if rel.parent.parts else "/"
170
+ name = rel.stem
171
+ path = (folder.rstrip("/") + "/" + name) if folder != "/" else "/" + name
172
+ rep = Report(file=str(rel), name=name, folder=folder, path=path)
173
+ try:
174
+ parser = etree.XMLParser(resolve_entities=False, no_network=True, huge_tree=False)
175
+ root = etree.fromstring(data, parser)
176
+ except etree.XMLSyntaxError as exc:
177
+ rep.parse_error = f"invalid XML: {str(exc)[:120]}"
178
+ return rep
179
+ if _local(root) != "Report":
180
+ rep.parse_error = f"not a report definition (root element {_local(root)})"
181
+ return rep
182
+ rep.rdl_version = RDL_NAMESPACES.get(etree.QName(root).namespace or "", "unknown")
183
+ rep.data_sources = _parse_datasources(root)
184
+ ds_holder = _child(root, "DataSets")
185
+ rep.datasets = [_parse_dataset(d) for d in _children(ds_holder, "DataSet")] if ds_holder is not None else []
186
+ params = _child(root, "ReportParameters")
187
+ for rp in _children(params, "ReportParameter") if params is not None else []:
188
+ default = None
189
+ dv = _child(rp, "DefaultValue")
190
+ if dv is not None:
191
+ vals = _child(dv, "Values")
192
+ first = _children(vals, "Value")[0] if vals is not None and _children(vals, "Value") else None
193
+ default = first.text.strip() if first is not None and first.text else None
194
+ rep.parameters.append(
195
+ {
196
+ "name": rp.get("Name", ""),
197
+ "type": _text(rp, "DataType"),
198
+ "prompt": _text(rp, "Prompt"),
199
+ "multi_value": (_text(rp, "MultiValue") or "").lower() == "true",
200
+ "hidden": (_text(rp, "Hidden") or "").lower() == "true",
201
+ "default": default,
202
+ "default_from_query": _child(dv, "DataSetReference") is not None if dv is not None else False,
203
+ }
204
+ )
205
+ rep.subreports = [_text(s, "ReportName") or "" for s in _iter(root, "Subreport")]
206
+ rep.drillthrough = [_text(d, "ReportName") or "" for d in _iter(root, "Drillthrough")]
207
+ code = _child(root, "Code")
208
+ if code is not None and (code.text or "").strip():
209
+ rep.has_custom_code = True
210
+ rep.custom_code_lines = len([ln for ln in code.text.splitlines() if ln.strip()])
211
+ rep.custom_assemblies = _child(root, "CodeModules") is not None or _child(root, "Classes") is not None
212
+ rep.embedded_images = len(list(_iter(_child(root, "EmbeddedImages"), "EmbeddedImage"))) if _child(root, "EmbeddedImages") is not None else 0
213
+ rep.expressions, rep.complex_expressions = _count_expressions(root)
214
+ counts: dict[str, int] = {}
215
+ for el in root.iter():
216
+ n = _local(el)
217
+ if n in LAYOUT_ELEMENTS:
218
+ counts[n] = counts.get(n, 0) + 1
219
+ rep.layout = counts
220
+ rep.has_page_header_footer = any(True for _ in _iter(root, "PageHeader")) or any(True for _ in _iter(root, "PageFooter"))
221
+ rep.toggles = len(list(_iter(root, "ToggleItem")))
222
+ return rep
223
+
224
+
225
+ def parse_rdl_file(path: Path, base: Path | None = None) -> Report:
226
+ try:
227
+ data = path.read_bytes()
228
+ except OSError as exc:
229
+ p = Path(path)
230
+ rel = p.relative_to(base) if base else p
231
+ return Report(file=str(rel), name=p.stem, folder="/", path="/" + p.stem, parse_error=f"cannot read file: {exc.strerror}")
232
+ return parse_rdl_bytes(data, str(path), base)
233
+
234
+
235
+ def parse_shared_dataset_bytes(data: bytes) -> DataSet | None:
236
+ try:
237
+ root = etree.fromstring(data, etree.XMLParser(resolve_entities=False, no_network=True))
238
+ except etree.XMLSyntaxError:
239
+ return None
240
+ ds = _child(root, "DataSet")
241
+ return _parse_dataset(ds) if ds is not None else None
242
+
243
+
244
+ def parse_shared_datasource_bytes(data: bytes) -> DataSource | None:
245
+ try:
246
+ root = etree.fromstring(data, etree.XMLParser(resolve_entities=False, no_network=True))
247
+ except etree.XMLSyntaxError:
248
+ return None
249
+ ds = DataSource(name="")
250
+ ds.provider = _text(root, "Extension")
251
+ cs = _text(root, "ConnectString")
252
+ ds.server, ds.database, ds.integrated_security, ds.embedded_credentials = _parse_connect_string(cs)
253
+ return ds
254
+
255
+
256
+ def layout_hint(rep: Report) -> str:
257
+ """Hint only: 'paginated' if the report is tabular and printable, 'interactive' if it leans on
258
+ charts, gauges, maps or show/hide toggles, 'unknown' when it cannot be told."""
259
+ if rep.parse_error:
260
+ return "unknown"
261
+ if any(k in rep.layout for k in INTERACTIVE_ELEMENTS) or rep.toggles:
262
+ return "interactive"
263
+ if rep.layout.get("Tablix") or rep.has_page_header_footer:
264
+ return "paginated"
265
+ return "unknown"
266
+
267
+
268
+ def non_sql_sources(rep: Report) -> list[str]:
269
+ return sorted({(d.provider or "").upper() for d in rep.data_sources if (d.provider or "").upper() in NON_SQL_PROVIDERS})
@@ -0,0 +1,120 @@
1
+ """Combine inventory, ssrs and validate outputs into one client-ready summary.html and workbook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections import Counter
7
+ from datetime import date
8
+ from importlib import resources
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from jinja2 import Environment, select_autoescape
13
+
14
+ from . import __version__
15
+ from .outputs import eos_info, order_workbook, update_sheet
16
+
17
+ FOOTER = "Generated with db-agent. Need the upgrade and report move done for you at a fixed price? becloudready.com/sql-server"
18
+
19
+
20
+ def _load(path: Path) -> Any:
21
+ return json.loads(path.read_text(encoding="utf-8")) if path.exists() else None
22
+
23
+
24
+ def build_summary(out: Path, today: date | None = None) -> dict[str, Any]:
25
+ inv, risks = _load(out / "inventory.json"), _load(out / "risks.json") or []
26
+ ssrs, val = _load(out / "reports.json"), _load(out / "validation.json")
27
+ s: dict[str, Any] = {"generated": (today or date.today()).isoformat(), "version": __version__, "footer": FOOTER,
28
+ "have_inventory": inv is not None, "have_ssrs": ssrs is not None, "have_validation": val is not None}
29
+ if inv:
30
+ user_dbs = [d for d in inv["databases"] if d["name"] not in ("master", "model", "msdb", "tempdb")]
31
+ s["server"] = inv["server"]
32
+ s["eos"] = eos_info(inv["server"]["product_version"], today)
33
+ s["db_count"] = len(user_dbs)
34
+ s["db_size_gb"] = round(sum((d["data_mb"] or 0) + (d["log_mb"] or 0) for d in user_dbs) / 1024, 1)
35
+ s["databases"] = user_dbs
36
+ s["risk_counts"] = dict(Counter(r["severity"] for r in risks))
37
+ grouped: dict[tuple[str, str], list[dict]] = {}
38
+ for r in risks:
39
+ grouped.setdefault((r["severity"], r["title"]), []).append(r)
40
+ order = {"high": 0, "medium": 1, "low": 2, "info": 3}
41
+ top = sorted(grouped.items(), key=lambda kv: (order[kv[0][0]], -len(kv[1])))[:10]
42
+ s["top_risks"] = [{"severity": k[0], "title": k[1], "count": len(v), "example": f"{v[0]['database']}: {v[0]['object']}".strip(": "),
43
+ "detail": v[0]["detail"][:160], "doc": v[0]["doc"]} for k, v in top]
44
+ s["azure_blockers"] = sum(1 for r in risks if r.get("azure_sql_database_only"))
45
+ s["warnings"] = inv.get("warnings", [])
46
+ if ssrs:
47
+ reps = ssrs["reports"]
48
+ used = [r for r in reps if (r["runs_365"] or 0) > 0]
49
+ s["ssrs"] = {
50
+ "total": ssrs["report_count"], "counts": ssrs["classification_counts"], "used": len(used),
51
+ "unused": ssrs["report_count"] - len(used), "usage": ssrs["usage"], "subscriptions_known": ssrs["subscriptions_known"],
52
+ "top_used": sorted(used, key=lambda r: -(r["runs_365"] or 0))[:20],
53
+ "tables": ssrs["table_usage"][:15],
54
+ "hard": {
55
+ "custom_code": sum(1 for r in reps if r["custom_code"]), "subreports": sum(1 for r in reps if r["subreports"]),
56
+ "drillthrough": sum(1 for r in reps if r["drillthrough"]), "subscriptions": sum(1 for r in reps if r["subscriptions"]),
57
+ "custom_assemblies": sum(1 for r in reps if r["custom_assemblies"]), "parse_problems": sum(1 for r in reps if r["parse_error"] or r["ref_confidence"] == "low"),
58
+ },
59
+ "review": [r for r in reps if r["classification"] == "review"],
60
+ "retire": [r for r in reps if r["classification"] == "retire"],
61
+ "paginated_hint": sum(1 for r in reps if r["layout_hint"] == "paginated"),
62
+ "interactive_hint": sum(1 for r in reps if r["layout_hint"] == "interactive"),
63
+ }
64
+ if val:
65
+ s["validation"] = {"counts": dict(Counter(v["status"] for v in val)), "rows": val}
66
+ s["next_steps"] = _next_steps(s)
67
+ return s
68
+
69
+
70
+ def _next_steps(s: dict[str, Any]) -> list[str]:
71
+ steps: list[str] = []
72
+ eos = s.get("eos")
73
+ if eos and eos.get("days") is not None:
74
+ if eos["days"] < 0:
75
+ steps.append(f"{eos['name']} is out of support. Pick the upgrade target and a date; Extended Security Updates are a stop-gap, not a plan.")
76
+ else:
77
+ steps.append(f"{eos['name']} loses support on {eos['date']}. Pick the upgrade target and schedule it before then.")
78
+ if s.get("risk_counts", {}).get("high"):
79
+ steps.append("Fix or plan around the high-severity risks first. They are features that are removed or not available in the target.")
80
+ if s.get("azure_blockers"):
81
+ steps.append("If the target is Azure SQL Database, review the risks marked 'Azure SQL Database only'. Managed Instance or SQL Server 2022 avoids most of them.")
82
+ ss = s.get("ssrs")
83
+ if ss:
84
+ if ss["counts"].get("retire"):
85
+ steps.append(f"Confirm the {ss['counts']['retire']} retire candidate(s) with their owners and drop them from the move.")
86
+ if ss["counts"].get("review"):
87
+ steps.append(f"Walk through the {ss['counts']['review']} review report(s). Each has a stated reason.")
88
+ if ss["counts"].get("move"):
89
+ steps.append(f"Start the move with the {ss['counts']['move']} plain report(s), then run `validate` to compare results.")
90
+ if (ss["usage"].get("log_window_days") or 0) < 365:
91
+ steps.append("Extend the SSRS execution log retention (ExecutionLogDaysKept) so the next run can prove which reports are unused.")
92
+ if s.get("have_ssrs") and not s.get("have_validation"):
93
+ steps.append("After the first reports are moved, run `validate` against the old and new servers.")
94
+ return steps
95
+
96
+
97
+ def write_report(out: Path, today: date | None = None) -> list[str]:
98
+ s = build_summary(out, today)
99
+ tpl = resources.files("db_agent.engines.sqlserver").joinpath("templates/summary.html.j2").read_text(encoding="utf-8")
100
+ env = Environment(autoescape=select_autoescape(default=True, default_for_string=True))
101
+ (out / "summary.html").write_text(env.from_string(tpl).render(s=s), encoding="utf-8")
102
+ rows: list[dict[str, Any]] = [{"item": "Generated", "value": s["generated"]}]
103
+ if s.get("server"):
104
+ rows += [{"item": "SQL Server", "value": f"{s['server']['product_version']} {s['server']['edition']}"},
105
+ {"item": "End of support", "value": s["eos"]["text"]},
106
+ {"item": "User databases", "value": f"{s['db_count']} ({s['db_size_gb']} GB)"},
107
+ {"item": "Risks", "value": ", ".join(f"{k}: {v}" for k, v in s["risk_counts"].items()) or "none"}]
108
+ if s.get("ssrs"):
109
+ c = s["ssrs"]["counts"]
110
+ rows += [{"item": "Reports", "value": s["ssrs"]["total"]}, {"item": "Move", "value": c.get("move", 0)},
111
+ {"item": "Review", "value": c.get("review", 0)}, {"item": "Retire", "value": c.get("retire", 0)},
112
+ {"item": "Used in last 365 days", "value": s["ssrs"]["used"]},
113
+ {"item": "Usage log reaches back (days)", "value": s["ssrs"]["usage"].get("log_window_days")}]
114
+ if s.get("validation"):
115
+ rows += [{"item": f"Validation {k}", "value": v} for k, v in s["validation"]["counts"].items()]
116
+ rows += [{"item": f"Next step {i}", "value": t} for i, t in enumerate(s["next_steps"], 1)]
117
+ rows += [{"item": "", "value": ""}, {"item": "", "value": FOOTER}]
118
+ update_sheet(out, "Summary", rows, ["item", "value"])
119
+ order_workbook(out)
120
+ return ["summary.html", "db-agent-sqlserver.xlsx (Summary)"]
@@ -0,0 +1,184 @@
1
+ """Turn an inventory into upgrade risks. Pure functions: no database access."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import asdict, dataclass
7
+ from typing import Any
8
+
9
+ from .inventory import major_version
10
+ from .rules import (
11
+ COMPAT_BY_MAJOR,
12
+ DOC_AZURE_DIFF,
13
+ DOC_COMPAT,
14
+ DOC_DEPRECATED,
15
+ DOC_DISCONTINUED,
16
+ scan_code,
17
+ )
18
+
19
+ SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2, "info": 3}
20
+ DEPRECATED_TYPES = {"text", "ntext", "image"}
21
+
22
+
23
+ @dataclass
24
+ class Risk:
25
+ id: str
26
+ severity: str
27
+ category: str
28
+ title: str
29
+ database: str
30
+ object: str
31
+ detail: str
32
+ doc: str
33
+ azure_sql_database_only: bool = False
34
+
35
+ def as_dict(self) -> dict[str, Any]:
36
+ return asdict(self)
37
+
38
+
39
+ def _fmt(schema: str | None, name: str) -> str:
40
+ return f"{schema}.{name}" if schema else name
41
+
42
+
43
+ def assess(inv: dict[str, Any], target_major: int = 16) -> list[Risk]:
44
+ """target_major: 15 = SQL Server 2019, 16 = SQL Server 2022."""
45
+ risks: list[Risk] = []
46
+ known_dbs = {d["name"].lower() for d in inv["databases"]}
47
+ server_major = major_version(inv["server"]["product_version"])
48
+ target_compat = COMPAT_BY_MAJOR.get(target_major, 160)
49
+
50
+ for d in inv["databases"]:
51
+ lvl = d["compatibility_level"]
52
+ if d["name"] in ("master", "model", "msdb", "tempdb"):
53
+ continue
54
+ if lvl <= 100:
55
+ risks.append(Risk("compat-deprecated", "high", "compatibility", f"Compatibility level {lvl} is deprecated",
56
+ d["name"], "", f"Level {lvl} is deprecated and removed in a future version. Test the application at {target_compat}.", DOC_COMPAT))
57
+ elif lvl < target_compat:
58
+ sev = "medium" if lvl <= 120 else "low"
59
+ risks.append(Risk("compat-below-target", sev, "compatibility", f"Compatibility level {lvl} is below {target_compat}",
60
+ d["name"], "", f"Levels 110 and 120 are marked for deprecation. Moving to {target_compat} changes query behaviour (cardinality estimator). Test before and after.", DOC_COMPAT))
61
+ if d["name"].upper() == "DQS_MAIN":
62
+ risks.append(Risk("dqs", "medium", "feature", "Data Quality Services database found", d["name"], "",
63
+ "DQS is deprecated since SQL Server 2016 and removed in SQL Server 2025.", DOC_DISCONTINUED))
64
+ if d.get("is_published") or d.get("is_merge_published") or d.get("is_subscribed") or d.get("is_distributor"):
65
+ risks.append(Risk("replication", "medium", "feature", "Replication is configured", d["name"], "",
66
+ "Replication needs a plan for the upgrade order (distributor, publisher, subscriber). Azure SQL Database can only be a subscriber.", DOC_AZURE_DIFF, True))
67
+
68
+ for ls in inv.get("linked_servers", []):
69
+ risks.append(Risk("linked-server", "medium", "dependency", "Linked server", "(server)", ls["name"],
70
+ f"Provider {ls.get('provider')}. Linked servers are not available in Azure SQL Database. Check the provider (SQLOLEDB is deprecated).",
71
+ DOC_AZURE_DIFF, True))
72
+ if str(ls.get("provider", "")).upper() == "SQLOLEDB":
73
+ risks.append(Risk("dep-sqloledb", "medium", "deprecated", "Linked server uses the SQLOLEDB provider", "(server)", ls["name"],
74
+ "Use Microsoft OLE DB Driver for SQL Server.", DOC_DEPRECATED))
75
+
76
+ for m in inv.get("mirroring", []):
77
+ risks.append(Risk("mirroring", "high", "deprecated", "Database mirroring in use", m["database_name"], "",
78
+ "Database mirroring is deprecated. Use Always On availability groups or log shipping.", DOC_DEPRECATED))
79
+ for e in inv.get("soap_endpoints", []):
80
+ risks.append(Risk("soap-endpoint", "high", "deprecated", "SOAP endpoint", "(server)", e["name"],
81
+ "Native XML web services (SOAP endpoints) are deprecated. Use WCF or ASP.NET.", DOC_DEPRECATED))
82
+ for c in inv.get("deprecated_feature_counters", []):
83
+ m = re.fullmatch(r"Database compatibility level (\d+)", c["feature"].strip(), re.IGNORECASE)
84
+ if m and int(m.group(1)) > 120:
85
+ continue # only levels 100, 110 and 120 are on Microsoft's deprecated list
86
+ risks.append(Risk("runtime-deprecated-feature", "medium", "deprecated", f"Deprecated feature used since last restart: {c['feature']}",
87
+ "(server)", c["feature"], f"Used {c['uses_since_restart']} time(s) since the instance last restarted, by the server's own count. "
88
+ "This can include management tools and the server itself, so check the code that uses it.",
89
+ DOC_DEPRECATED))
90
+
91
+ for dbname, db in inv.get("objects", {}).items():
92
+ for t in db["tables"]:
93
+ for c in t["columns"]:
94
+ if c["type_name"] in DEPRECATED_TYPES:
95
+ risks.append(Risk("dep-text-ntext-image", "medium", "deprecated", f"Deprecated data type {c['type_name']}", dbname,
96
+ f"{_fmt(t['schema'], t['name'])}.{c['column_name']}", "Use varchar(max), nvarchar(max) or varbinary(max).", DOC_DEPRECATED))
97
+ for s in db["stretch_tables"]:
98
+ risks.append(Risk("stretch", "high", "discontinued", "Stretch Database is enabled", dbname, _fmt(s["schema_name"], s["table_name"]),
99
+ "Stretch Database was discontinued in July 2024 in all supported versions. Data in Azure must be brought back on premises.", DOC_DISCONTINUED))
100
+ for a in db["assemblies"]:
101
+ risks.append(Risk("clr", "medium", "feature", "CLR assembly", dbname, a["name"],
102
+ f"Permission set {a['permission_set_desc']}. CLR is not available in Azure SQL Database (Managed Instance supports it). Review the code before an upgrade.", DOC_AZURE_DIFF, True))
103
+ for q in db["service_broker_queues"]:
104
+ risks.append(Risk("service-broker", "medium", "feature", "Service Broker queue", dbname, q["name"],
105
+ "Service Broker is not available in Azure SQL Database.", DOC_AZURE_DIFF, True))
106
+ for f in db["fulltext_catalogs"]:
107
+ risks.append(Risk("fulltext", "low", "feature", "Full-text catalog", dbname, f["name"],
108
+ "Rebuild and test full-text indexes after an upgrade.", DOC_AZURE_DIFF))
109
+ if db["filestream_columns"]:
110
+ risks.append(Risk("filestream", "medium", "feature", "FILESTREAM columns", dbname, "",
111
+ f"{db['filestream_columns']} column(s). Not available in Azure SQL Database.", DOC_AZURE_DIFF, True))
112
+ for x in db["standalone_defaults_rules"]:
113
+ risks.append(Risk("dep-create-default-rule", "low", "deprecated", f"Stand-alone {x['type_desc']}", dbname, _fmt(x["schema_name"], x["name"]),
114
+ "Use DEFAULT and CHECK constraints.", DOC_DEPRECATED))
115
+ for x in db["cross_references"]:
116
+ target = ".".join(p for p in (x["referenced_server_name"], x["referenced_database_name"], x["referenced_schema_name"], x["referenced_entity_name"]) if p)
117
+ if x["referenced_server_name"]:
118
+ risks.append(Risk("cross-server-ref", "high", "dependency", "Reference through a linked server", dbname,
119
+ _fmt(x["referencing_schema"], x["referencing_object"]), f"References {target}.", DOC_AZURE_DIFF, True))
120
+ elif x["referenced_database_name"] and x["referenced_database_name"].lower() != dbname.lower():
121
+ if x["referenced_database_name"].lower() in known_dbs:
122
+ risks.append(Risk("cross-db-ref", "medium", "dependency", "Cross-database reference", dbname,
123
+ _fmt(x["referencing_schema"], x["referencing_object"]), f"References {target}. Cross-database queries are not supported in Azure SQL Database.", DOC_AZURE_DIFF, True))
124
+ else:
125
+ # The dependency view also lists XML method calls (for example Resume.ref.value) as if they were
126
+ # database references, so a name that is not a database here cannot be called a dependency with
127
+ # certainty. It can also be a real database that is missing from, or lives outside, this server.
128
+ risks.append(Risk("cross-db-ref-unresolved", "low", "dependency", "Reference to a name that is not a database on this server",
129
+ dbname, _fmt(x["referencing_schema"], x["referencing_object"]),
130
+ f"References {target}. Either a database that lives elsewhere (check it is moved too) or an XML method call that the "
131
+ "dependency view lists as a database reference.", DOC_AZURE_DIFF))
132
+ for s in db["synonyms"]:
133
+ parts = re.sub(r"[\[\]]", "", s["base_object_name"] or "").split(".")
134
+ if len(parts) >= 4:
135
+ risks.append(Risk("synonym-linked-server", "high", "dependency", "Synonym points through a linked server", dbname, s["name"], s["base_object_name"], DOC_AZURE_DIFF, True))
136
+ elif len(parts) == 3 and parts[0].lower() != dbname.lower():
137
+ risks.append(Risk("synonym-cross-db", "medium", "dependency", "Synonym points to another database", dbname, s["name"], s["base_object_name"], DOC_AZURE_DIFF, True))
138
+ for m in db["modules"]:
139
+ obj = _fmt(m["schema"], m["name"])
140
+ if m["encrypted"]:
141
+ risks.append(Risk("encrypted-module", "info", "coverage", "Module is encrypted, code could not be scanned", dbname, obj,
142
+ f"{m['type']} created WITH ENCRYPTION. Ask the vendor or owner.", DOC_DEPRECATED))
143
+ continue
144
+ if m["uses_ansi_nulls"] == 0:
145
+ risks.append(Risk("dep-set-ansi-off", "medium", "deprecated", "Module stored with ANSI_NULLS OFF", dbname, obj,
146
+ "ANSI_NULLS OFF is deprecated and will always be ON in a future version.", DOC_DEPRECATED))
147
+ seen: set[tuple[str, int]] = set()
148
+ for rule, line, text in scan_code(m["definition"] or ""):
149
+ if (rule.id, line) in seen:
150
+ continue
151
+ seen.add((rule.id, line))
152
+ risks.append(Risk(rule.id, rule.severity, "deprecated", rule.title, dbname, obj,
153
+ f"Line {line}: {text[:80]}. {rule.replacement}", rule.doc))
154
+ if re.search(r"\bxp_cmdshell\b", m["definition"] or "", re.IGNORECASE):
155
+ risks.append(Risk("xp-cmdshell", "medium", "feature", "Uses xp_cmdshell", dbname, obj,
156
+ "Not available in Azure SQL Database. Review the security implications.", DOC_AZURE_DIFF, True))
157
+
158
+ for job in inv.get("agent_jobs", []):
159
+ cmd = job.get("command") or ""
160
+ label = f"{job['job_name']} step {job['step_id']}"
161
+ if job.get("subsystem") == "TSQL":
162
+ for rule, line, text in scan_code(cmd):
163
+ risks.append(Risk(rule.id, rule.severity, "deprecated", rule.title, job.get("database_name") or "(agent)", label,
164
+ f"Line {line}: {text[:80]}. {rule.replacement}", rule.doc))
165
+ if inv.get("agent_jobs"):
166
+ names = {j["job_name"] for j in inv["agent_jobs"]}
167
+ risks.append(Risk("agent-jobs", "low", "feature", f"{len(names)} SQL Server Agent job(s)", "(msdb)", "",
168
+ "SQL Server Agent is not available in Azure SQL Database (use elastic jobs or another scheduler). Move the jobs with the databases.", DOC_AZURE_DIFF, True))
169
+
170
+ if server_major in (13, 14):
171
+ risks.append(Risk("end-of-support", "high", "lifecycle", f"SQL Server {inv['server']['product_version']} is at or near end of support",
172
+ "(server)", "", "See the lifecycle dates in the summary.", "https://learn.microsoft.com/en-us/lifecycle/products/sql-server-2016"
173
+ if server_major == 13 else "https://learn.microsoft.com/en-us/lifecycle/products/sql-server-2017"))
174
+ risks.sort(key=lambda r: (SEVERITY_ORDER[r.severity], r.category, r.database, r.object))
175
+ return risks
176
+
177
+
178
+ def strip_secrets(inv: dict[str, Any]) -> dict[str, Any]:
179
+ """Return a copy safe to save: Agent step commands and linked-server data sources are dropped
180
+ (they can hold credentials). Module code is kept in a separate file by the caller."""
181
+ safe = {k: v for k, v in inv.items() if k not in ("agent_jobs",)}
182
+ safe["agent_jobs"] = [{k: v for k, v in j.items() if k != "command"} for j in inv.get("agent_jobs", [])]
183
+ safe["linked_servers"] = [{k: v for k, v in ls.items() if k != "data_source"} for ls in inv.get("linked_servers", [])]
184
+ return safe