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,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
|