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,324 @@
1
+ """Server and database inventory. Metadata only: no business table is ever read.
2
+
3
+ All SQL here is a fixed string and passes through the QueryGuard like everything else.
4
+ Row counts come from sys.dm_db_partition_stats.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ from collections import defaultdict
12
+ from typing import Any, Callable
13
+
14
+ from .db import ConnectionConfig, DatabaseError, SafeConnection
15
+ from .guard import GuardError, QueryGuard
16
+
17
+ SYSTEM_DATABASES = {"master", "model", "msdb", "tempdb"}
18
+
19
+ Q_SERVER = """
20
+ SELECT CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)) AS product_version,
21
+ CAST(SERVERPROPERTY('Edition') AS nvarchar(128)) AS edition,
22
+ CAST(SERVERPROPERTY('ProductLevel') AS nvarchar(128)) AS product_level,
23
+ CAST(SERVERPROPERTY('Collation') AS nvarchar(128)) AS collation,
24
+ CAST(SERVERPROPERTY('EngineEdition') AS int) AS engine_edition,
25
+ CAST(SERVERPROPERTY('ServerName') AS nvarchar(128)) AS server_name
26
+ """
27
+
28
+ Q_DATABASES = """
29
+ SELECT d.name, d.database_id, d.compatibility_level, d.collation_name, d.state_desc,
30
+ d.recovery_model_desc, d.is_published, d.is_subscribed, d.is_merge_published, d.is_distributor,
31
+ CAST(SUM(CASE WHEN mf.type = 0 THEN CAST(mf.size AS bigint) ELSE 0 END) * 8 / 1024 AS bigint) AS data_mb,
32
+ CAST(SUM(CASE WHEN mf.type = 1 THEN CAST(mf.size AS bigint) ELSE 0 END) * 8 / 1024 AS bigint) AS log_mb
33
+ FROM sys.databases d
34
+ LEFT JOIN sys.master_files mf ON mf.database_id = d.database_id
35
+ GROUP BY d.name, d.database_id, d.compatibility_level, d.collation_name, d.state_desc,
36
+ d.recovery_model_desc, d.is_published, d.is_subscribed, d.is_merge_published, d.is_distributor
37
+ ORDER BY d.name
38
+ """
39
+
40
+ # Azure SQL Database has no sys.master_files. Fall back to sys.databases alone (sizes unknown), then
41
+ # fill in the size of the connected database from sys.database_files.
42
+ Q_DATABASES_BASIC = """
43
+ SELECT d.name, d.database_id, d.compatibility_level, d.collation_name, d.state_desc,
44
+ d.recovery_model_desc, d.is_published, d.is_subscribed, d.is_merge_published, d.is_distributor,
45
+ CAST(NULL AS bigint) AS data_mb, CAST(NULL AS bigint) AS log_mb
46
+ FROM sys.databases d
47
+ ORDER BY d.name
48
+ """
49
+ Q_DATABASES_BASIC_MIN = """
50
+ SELECT d.name, d.database_id, d.compatibility_level, d.collation_name, d.state_desc,
51
+ CAST(NULL AS nvarchar(60)) AS recovery_model_desc,
52
+ CAST(0 AS bit) AS is_published, CAST(0 AS bit) AS is_subscribed, CAST(0 AS bit) AS is_merge_published,
53
+ CAST(0 AS bit) AS is_distributor, CAST(NULL AS bigint) AS data_mb, CAST(NULL AS bigint) AS log_mb
54
+ FROM sys.databases d
55
+ ORDER BY d.name
56
+ """
57
+ Q_OWN_FILES = """
58
+ SELECT CAST(SUM(CASE WHEN type = 0 THEN CAST(size AS bigint) ELSE 0 END) * 8 / 1024 AS bigint) AS data_mb,
59
+ CAST(SUM(CASE WHEN type = 1 THEN CAST(size AS bigint) ELSE 0 END) * 8 / 1024 AS bigint) AS log_mb
60
+ FROM sys.database_files
61
+ """
62
+
63
+ Q_LINKED = "SELECT name, product, provider, data_source FROM sys.servers WHERE is_linked = 1"
64
+ Q_DEPRECATED_COUNTERS = (
65
+ "SELECT RTRIM(instance_name) AS feature, cntr_value AS uses_since_restart "
66
+ "FROM sys.dm_os_performance_counters "
67
+ "WHERE object_name LIKE '%Deprecated Features%' AND cntr_value > 0 ORDER BY cntr_value DESC"
68
+ )
69
+ Q_MIRRORING = "SELECT DB_NAME(database_id) AS database_name FROM sys.database_mirroring WHERE mirroring_guid IS NOT NULL"
70
+ Q_SOAP = "SELECT name FROM sys.endpoints WHERE type_desc = 'SOAP'"
71
+ Q_AGENT_JOBS = """
72
+ SELECT j.name AS job_name, j.enabled, js.step_id, js.step_name, js.subsystem, js.database_name,
73
+ js.command
74
+ FROM msdb.dbo.sysjobs j
75
+ JOIN msdb.dbo.sysjobsteps js ON js.job_id = j.job_id
76
+ ORDER BY j.name, js.step_id
77
+ """
78
+ Q_AGENT_SCHEDULES = """
79
+ SELECT j.name AS job_name, s.name AS schedule_name, s.enabled, s.freq_type, s.freq_interval
80
+ FROM msdb.dbo.sysjobs j
81
+ JOIN msdb.dbo.sysjobschedules js ON js.job_id = j.job_id
82
+ JOIN msdb.dbo.sysschedules s ON s.schedule_id = js.schedule_id
83
+ """
84
+
85
+ # Per-database queries
86
+ Q_TABLES = """
87
+ SELECT s.name AS schema_name, t.name AS table_name, t.object_id, t.create_date, t.modify_date
88
+ FROM sys.tables t JOIN sys.schemas s ON s.schema_id = t.schema_id
89
+ WHERE t.is_ms_shipped = 0
90
+ """
91
+ Q_ROWCOUNTS = (
92
+ "SELECT object_id, SUM(row_count) AS row_count FROM sys.dm_db_partition_stats "
93
+ "WHERE index_id IN (0, 1) GROUP BY object_id"
94
+ )
95
+ Q_COLUMNS = """
96
+ SELECT o.object_id, s.name AS schema_name, o.name AS object_name, o.type AS object_type,
97
+ c.column_id, c.name AS column_name, ty.name AS type_name, c.max_length, c.precision, c.scale,
98
+ c.is_nullable, c.is_identity, c.is_computed
99
+ FROM sys.columns c
100
+ JOIN sys.objects o ON o.object_id = c.object_id
101
+ JOIN sys.schemas s ON s.schema_id = o.schema_id
102
+ JOIN sys.types ty ON ty.user_type_id = c.user_type_id
103
+ WHERE o.type IN ('U', 'V') AND o.is_ms_shipped = 0
104
+ ORDER BY o.object_id, c.column_id
105
+ """
106
+ Q_INDEXES = """
107
+ SELECT i.object_id, i.name AS index_name, i.type_desc, i.is_primary_key, i.is_unique,
108
+ ic.key_ordinal, c.name AS column_name
109
+ FROM sys.indexes i
110
+ JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id
111
+ JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
112
+ JOIN sys.objects o ON o.object_id = i.object_id
113
+ WHERE o.is_ms_shipped = 0 AND o.type = 'U' AND i.type > 0 AND ic.is_included_column = 0
114
+ ORDER BY i.object_id, i.index_id, ic.key_ordinal
115
+ """
116
+ Q_MODULES = """
117
+ SELECT s.name AS schema_name, o.name AS object_name, o.type AS object_type, o.type_desc,
118
+ o.create_date, o.modify_date, m.uses_ansi_nulls, m.uses_quoted_identifier, m.definition
119
+ FROM sys.sql_modules m
120
+ JOIN sys.objects o ON o.object_id = m.object_id
121
+ JOIN sys.schemas s ON s.schema_id = o.schema_id
122
+ WHERE o.is_ms_shipped = 0
123
+ ORDER BY s.name, o.name
124
+ """
125
+ Q_DB_TRIGGERS = """
126
+ SELECT t.name AS object_name, m.uses_ansi_nulls, m.uses_quoted_identifier, m.definition
127
+ FROM sys.triggers t JOIN sys.sql_modules m ON m.object_id = t.object_id
128
+ WHERE t.parent_class = 0 AND t.is_ms_shipped = 0
129
+ """
130
+ Q_TRIGGERS = """
131
+ SELECT t.name AS trigger_name, t.parent_class_desc, OBJECT_NAME(t.parent_id) AS parent_name,
132
+ t.is_disabled, t.is_instead_of_trigger
133
+ FROM sys.triggers t WHERE t.is_ms_shipped = 0
134
+ """
135
+ Q_SYNONYMS = "SELECT name, base_object_name FROM sys.synonyms"
136
+ Q_XDEPS = """
137
+ SELECT OBJECT_SCHEMA_NAME(d.referencing_id) AS referencing_schema,
138
+ OBJECT_NAME(d.referencing_id) AS referencing_object,
139
+ d.referenced_server_name, d.referenced_database_name, d.referenced_schema_name, d.referenced_entity_name
140
+ FROM sys.sql_expression_dependencies d
141
+ WHERE d.referenced_server_name IS NOT NULL OR d.referenced_database_name IS NOT NULL
142
+ """
143
+ Q_ASSEMBLIES = "SELECT name, permission_set_desc FROM sys.assemblies WHERE is_user_defined = 1"
144
+ Q_BROKER = "SELECT name FROM sys.service_queues WHERE is_ms_shipped = 0"
145
+ Q_FULLTEXT = "SELECT name FROM sys.fulltext_catalogs"
146
+ Q_STRETCH = """
147
+ SELECT s.name AS schema_name, t.name AS table_name
148
+ FROM sys.tables t JOIN sys.schemas s ON s.schema_id = t.schema_id
149
+ WHERE t.is_remote_data_archive_enabled = 1
150
+ """
151
+ Q_FILESTREAM = "SELECT COUNT(*) AS n FROM sys.columns WHERE is_filestream = 1"
152
+ Q_STANDALONE_DEFAULTS_RULES = (
153
+ "SELECT SCHEMA_NAME(schema_id) AS schema_name, name, type_desc FROM sys.objects "
154
+ "WHERE type IN ('D', 'R') AND parent_object_id = 0 AND is_ms_shipped = 0"
155
+ )
156
+
157
+
158
+ def major_version(product_version: str) -> int:
159
+ try:
160
+ return int(str(product_version).split(".")[0])
161
+ except (ValueError, IndexError):
162
+ return 0
163
+
164
+
165
+ def _optional(
166
+ warnings: list[str], label: str, fn: Callable[[], Any], default: Any
167
+ ) -> Any:
168
+ """Run a query that may be refused by permissions or missing in this version."""
169
+ try:
170
+ return fn()
171
+ except (DatabaseError, GuardError) as exc:
172
+ msg = str(exc)
173
+ m = re.search(r"Invalid object name '([^']+)'", msg)
174
+ if m:
175
+ warnings.append(f"{label}: skipped, {m.group(1)} does not exist on this server type (expected on Azure SQL Database)")
176
+ else:
177
+ warnings.append(f"{label}: skipped ({msg[:160]})")
178
+ return default
179
+
180
+
181
+ def collect_database(conn: SafeConnection, name: str, warnings: list[str]) -> dict[str, Any]:
182
+ """Collect objects for the database that `conn` is connected to."""
183
+ db: dict[str, Any] = {"name": name}
184
+ tables = _optional(warnings, f"{name}: tables", lambda: conn.query(Q_TABLES), [])
185
+ counts = {
186
+ r["object_id"]: int(r["row_count"] or 0)
187
+ for r in _optional(warnings, f"{name}: row counts", lambda: conn.query(Q_ROWCOUNTS), [])
188
+ }
189
+ cols_by_obj: dict[int, list[dict]] = defaultdict(list)
190
+ objects: dict[int, dict] = {}
191
+ for r in _optional(warnings, f"{name}: columns", lambda: conn.query(Q_COLUMNS), []):
192
+ cols_by_obj[r["object_id"]].append(
193
+ {k: r[k] for k in ("column_name", "type_name", "max_length", "precision", "scale", "is_nullable", "is_identity", "is_computed")}
194
+ )
195
+ objects[r["object_id"]] = {"schema": r["schema_name"], "name": r["object_name"], "type": (r["object_type"] or "").strip()}
196
+ idx: dict[int, dict[str, dict]] = defaultdict(dict)
197
+ for r in _optional(warnings, f"{name}: indexes", lambda: conn.query(Q_INDEXES), []):
198
+ e = idx[r["object_id"]].setdefault(
199
+ r["index_name"] or "(heap)",
200
+ {"type": r["type_desc"], "primary_key": bool(r["is_primary_key"]), "unique": bool(r["is_unique"]), "columns": []},
201
+ )
202
+ e["columns"].append(r["column_name"])
203
+ db["tables"] = [
204
+ {
205
+ "schema": t["schema_name"],
206
+ "name": t["table_name"],
207
+ "rows": counts.get(t["object_id"]),
208
+ "columns": cols_by_obj.get(t["object_id"], []),
209
+ "indexes": [{"name": k, **v} for k, v in idx.get(t["object_id"], {}).items()],
210
+ "created": str(t["create_date"]),
211
+ "modified": str(t["modify_date"]),
212
+ }
213
+ for t in tables
214
+ ]
215
+ db["views"] = [
216
+ {"schema": o["schema"], "name": o["name"], "columns": cols_by_obj.get(oid, [])}
217
+ for oid, o in objects.items()
218
+ if o["type"] == "V"
219
+ ]
220
+ modules = _optional(warnings, f"{name}: modules", lambda: conn.query(Q_MODULES), [])
221
+ modules += [
222
+ {**r, "schema_name": "(database trigger)", "object_type": "TR", "type_desc": "DATABASE_DDL_TRIGGER"}
223
+ for r in _optional(warnings, f"{name}: database triggers", lambda: conn.query(Q_DB_TRIGGERS), [])
224
+ ]
225
+ db["modules"] = [
226
+ {
227
+ "schema": m["schema_name"],
228
+ "name": m["object_name"],
229
+ "type": m["type_desc"],
230
+ "created": str(m.get("create_date", "")),
231
+ "modified": str(m.get("modify_date", "")),
232
+ "uses_ansi_nulls": m["uses_ansi_nulls"],
233
+ "uses_quoted_identifier": m["uses_quoted_identifier"],
234
+ "encrypted": m["definition"] is None,
235
+ "definition": m["definition"],
236
+ }
237
+ for m in modules
238
+ ]
239
+ db["triggers"] = _optional(warnings, f"{name}: triggers", lambda: conn.query(Q_TRIGGERS), [])
240
+ db["synonyms"] = _optional(warnings, f"{name}: synonyms", lambda: conn.query(Q_SYNONYMS), [])
241
+ db["cross_references"] = _optional(warnings, f"{name}: cross-database references", lambda: conn.query(Q_XDEPS), [])
242
+ db["assemblies"] = _optional(warnings, f"{name}: assemblies", lambda: conn.query(Q_ASSEMBLIES), [])
243
+ db["service_broker_queues"] = _optional(warnings, f"{name}: service broker", lambda: conn.query(Q_BROKER), [])
244
+ db["fulltext_catalogs"] = _optional(warnings, f"{name}: full-text", lambda: conn.query(Q_FULLTEXT), [])
245
+ db["stretch_tables"] = _optional(warnings, f"{name}: stretch database", lambda: conn.query(Q_STRETCH), [])
246
+ fs = _optional(warnings, f"{name}: filestream", lambda: conn.query(Q_FILESTREAM), [{"n": 0}])
247
+ db["filestream_columns"] = int(fs[0]["n"]) if fs else 0
248
+ db["standalone_defaults_rules"] = _optional(warnings, f"{name}: defaults and rules", lambda: conn.query(Q_STANDALONE_DEFAULTS_RULES), [])
249
+ return db
250
+
251
+
252
+ def collect(
253
+ cfg: ConnectionConfig,
254
+ databases: list[str] | None = None,
255
+ include_system: bool = False,
256
+ guard: QueryGuard | None = None,
257
+ progress: Callable[[str], None] | None = None,
258
+ ) -> dict[str, Any]:
259
+ """Collect the full inventory. `databases` limits which ones are read."""
260
+ say = progress or (lambda _m: None)
261
+ warnings: list[str] = []
262
+ guard = guard or QueryGuard()
263
+ inv: dict[str, Any] = {"warnings": warnings}
264
+ with SafeConnection(cfg.with_database("master") if not cfg.database else cfg, guard) as master:
265
+ inv["server"] = master.query(Q_SERVER)[0]
266
+ try:
267
+ all_dbs = master.query(Q_DATABASES)
268
+ except DatabaseError as exc:
269
+ if "master_files" not in str(exc):
270
+ raise
271
+ warnings.append("database sizes: sys.master_files is not available here (Azure SQL Database), sizes are for the connected database only")
272
+ try:
273
+ all_dbs = master.query(Q_DATABASES_BASIC)
274
+ except DatabaseError:
275
+ all_dbs = master.query(Q_DATABASES_BASIC_MIN)
276
+ own = _optional(warnings, "own database size", lambda: master.query(Q_OWN_FILES), [])
277
+ cur = _optional(warnings, "current database name", lambda: master.query("SELECT DB_NAME() AS n"), [])
278
+ if own and cur:
279
+ for d in all_dbs:
280
+ if d["name"] == cur[0]["n"]:
281
+ d["data_mb"], d["log_mb"] = own[0]["data_mb"], own[0]["log_mb"]
282
+ inv["linked_servers"] = _optional(warnings, "linked servers", lambda: master.query(Q_LINKED), [])
283
+ azure_db = inv["server"].get("engine_edition") == 5 # Azure SQL Database
284
+ if azure_db:
285
+ # Platform tooling and sample-data loading use deprecated features on the service's own behalf.
286
+ inv["deprecated_feature_counters"] = []
287
+ warnings.append("deprecated feature counters: not read on Azure SQL Database (the counts reflect the platform's own use)")
288
+ else:
289
+ inv["deprecated_feature_counters"] = _optional(
290
+ warnings, "deprecated feature counters (needs VIEW SERVER STATE)", lambda: master.query(Q_DEPRECATED_COUNTERS), []
291
+ )
292
+ inv["mirroring"] = _optional(warnings, "database mirroring", lambda: master.query(Q_MIRRORING), [])
293
+ inv["soap_endpoints"] = _optional(warnings, "soap endpoints", lambda: master.query(Q_SOAP), [])
294
+ inv["databases"] = all_dbs
295
+ wanted = [
296
+ d["name"]
297
+ for d in all_dbs
298
+ if d["state_desc"] == "ONLINE"
299
+ and (databases is None or d["name"] in databases)
300
+ and (include_system or d["name"] not in SYSTEM_DATABASES)
301
+ ]
302
+ inv["objects"] = {}
303
+ for name in wanted:
304
+ say(f"reading {name}")
305
+ try:
306
+ with SafeConnection(cfg.with_database(name), guard) as c:
307
+ inv["objects"][name] = collect_database(c, name, warnings)
308
+ except DatabaseError as exc:
309
+ warnings.append(f"{name}: skipped, cannot connect ({str(exc)[:160]})")
310
+ if inv["server"].get("engine_edition") == 5:
311
+ warnings.append("SQL Server Agent jobs: skipped, Azure SQL Database has no SQL Server Agent")
312
+ inv["agent_jobs"], inv["agent_schedules"] = [], []
313
+ return inv
314
+ say("reading SQL Server Agent jobs")
315
+ try:
316
+ with SafeConnection(cfg.with_database("msdb"), guard) as c:
317
+ jobs = c.query(Q_AGENT_JOBS)
318
+ sched = c.query(Q_AGENT_SCHEDULES)
319
+ inv["agent_jobs"] = jobs # command text is scanned for risks, then dropped before saving
320
+ inv["agent_schedules"] = sched
321
+ except (DatabaseError, GuardError) as exc:
322
+ warnings.append(f"SQL Server Agent jobs: skipped ({str(exc)[:160]})")
323
+ inv["agent_jobs"], inv["agent_schedules"] = [], []
324
+ return inv
@@ -0,0 +1,299 @@
1
+ """Write results to the output folder: Markdown, JSON, CSV, and tabs of one Excel workbook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import json
7
+ import re
8
+ from datetime import date, datetime
9
+ from decimal import Decimal
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from openpyxl import Workbook, load_workbook
14
+ from openpyxl.styles import Font, PatternFill
15
+ from openpyxl.utils import get_column_letter
16
+
17
+ from . import END_OF_SUPPORT
18
+ from .inventory import major_version
19
+
20
+ WORKBOOK = "db-agent-sqlserver.xlsx"
21
+ FORMATS = ("md", "json", "xlsx", "html")
22
+
23
+
24
+ def _default(o: Any) -> Any:
25
+ if isinstance(o, (datetime, date)):
26
+ return o.isoformat(sep=" ") if isinstance(o, datetime) else o.isoformat()
27
+ if isinstance(o, Decimal):
28
+ return float(o)
29
+ if isinstance(o, (bytes, bytearray)):
30
+ return f"<{len(o)} bytes>"
31
+ return str(o)
32
+
33
+
34
+ def write_json(path: Path, data: Any) -> None:
35
+ path.write_text(json.dumps(data, indent=2, default=_default, ensure_ascii=False) + "\n", encoding="utf-8")
36
+
37
+
38
+ def write_csv(path: Path, rows: list[dict[str, Any]], columns: list[str]) -> None:
39
+ with open(path, "w", newline="", encoding="utf-8") as fh:
40
+ w = csv.DictWriter(fh, fieldnames=columns, extrasaction="ignore")
41
+ w.writeheader()
42
+ for r in rows:
43
+ w.writerow({c: _cell(r.get(c)) for c in columns})
44
+
45
+
46
+ def _cell(v: Any) -> Any:
47
+ if isinstance(v, (list, tuple, set)):
48
+ return "; ".join(str(x) for x in v)
49
+ if isinstance(v, dict):
50
+ return json.dumps(v, default=_default)
51
+ if isinstance(v, bool):
52
+ return "yes" if v else "no"
53
+ return "" if v is None else v
54
+
55
+
56
+ def update_sheet(out: Path, sheet: str, rows: list[dict[str, Any]], columns: list[str]) -> None:
57
+ """Create or replace one tab of the shared workbook."""
58
+ path = out / WORKBOOK
59
+ wb = load_workbook(path) if path.exists() else Workbook()
60
+ if sheet in wb.sheetnames:
61
+ del wb[sheet]
62
+ if "Sheet" in wb.sheetnames and len(wb.sheetnames) == 1:
63
+ del wb["Sheet"]
64
+ ws = wb.create_sheet(sheet)
65
+ ws.append(columns)
66
+ for c in ws[1]:
67
+ c.font = Font(bold=True, color="FFFFFF")
68
+ c.fill = PatternFill("solid", fgColor="1F3A5F")
69
+ for r in rows:
70
+ ws.append([_xl(_cell(r.get(c))) for c in columns])
71
+ for i, col in enumerate(columns, start=1):
72
+ width = max([len(str(col))] + [len(str(_cell(r.get(col)))) for r in rows[:200]])
73
+ ws.column_dimensions[get_column_letter(i)].width = min(max(width + 2, 8), 70)
74
+ ws.freeze_panes = "A2"
75
+ wb.save(path)
76
+
77
+
78
+ def _xl(v: Any) -> Any:
79
+ if isinstance(v, str) and v and v[0] in "=+-@":
80
+ return "'" + v # stop Excel treating text as a formula
81
+ return v
82
+
83
+
84
+ def order_workbook(out: Path) -> None:
85
+ path = out / WORKBOOK
86
+ if not path.exists():
87
+ return
88
+ wb = load_workbook(path)
89
+ order = ["Summary", "Reports", "Risks", "Inventory", "Validation"]
90
+ wb._sheets = sorted(wb._sheets, key=lambda s: order.index(s.title) if s.title in order else 99)
91
+ wb.save(path)
92
+
93
+
94
+ def eos_info(product_version: str, today: date | None = None) -> dict[str, Any]:
95
+ major = major_version(product_version)
96
+ today = today or date.today()
97
+ if major in END_OF_SUPPORT:
98
+ name, d = END_OF_SUPPORT[major]
99
+ eos = date.fromisoformat(d)
100
+ days = (eos - today).days
101
+ return {"name": name, "date": d, "days": days,
102
+ "text": f"{name} end of support: {d} ({'ended ' + str(-days) + ' days ago' if days < 0 else str(days) + ' days left'})"}
103
+ return {"name": f"SQL Server {product_version}", "date": None, "days": None,
104
+ "text": "End-of-support date not in this kit's table. Check Microsoft's lifecycle page."}
105
+
106
+
107
+ def safe_filename(path: str) -> str:
108
+ return re.sub(r"[^A-Za-z0-9._-]+", "_", path.strip("/")) or "report"
109
+
110
+
111
+ # ---- inventory ------------------------------------------------------------------------------
112
+ def inventory_rows(inv: dict[str, Any]) -> list[dict[str, Any]]:
113
+ rows = []
114
+ for dbname, db in inv["objects"].items():
115
+ for t in db["tables"]:
116
+ rows.append({"database": dbname, "kind": "table", "schema": t["schema"], "name": t["name"], "rows": t["rows"],
117
+ "columns": len(t["columns"]), "indexes": len(t["indexes"]), "detail": ""})
118
+ for v in db["views"]:
119
+ rows.append({"database": dbname, "kind": "view", "schema": v["schema"], "name": v["name"], "rows": None,
120
+ "columns": len(v["columns"]), "indexes": None, "detail": ""})
121
+ for m in db["modules"]:
122
+ rows.append({"database": dbname, "kind": m["type"].lower(), "schema": m["schema"], "name": m["name"], "rows": None,
123
+ "columns": None, "indexes": None, "detail": "encrypted" if m["encrypted"] else ""})
124
+ return rows
125
+
126
+
127
+ def render_inventory_md(inv: dict[str, Any], risks: list[dict[str, Any]]) -> str:
128
+ s = inv["server"]
129
+ eos = eos_info(s["product_version"])
130
+ L = ["# SQL Server inventory", "",
131
+ f"- Server: {s['server_name']}",
132
+ f"- Version: {s['product_version']} {s['product_level']}, {s['edition']}",
133
+ f"- Collation: {s['collation']}", f"- {eos['text']}", ""]
134
+ L += ["## Databases", "", "| Database | Compat level | Data MB | Log MB | State |", "|---|---|---|---|---|"]
135
+ for d in inv["databases"]:
136
+ L.append(f"| {d['name']} | {d['compatibility_level']} | {d['data_mb']} | {d['log_mb']} | {d['state_desc']} |")
137
+ L += ["", "## Objects per database", "", "| Database | Tables | Rows (metadata) | Views | Procedures, functions, triggers |", "|---|---|---|---|---|"]
138
+ for name, db in inv["objects"].items():
139
+ total = sum((t["rows"] or 0) for t in db["tables"])
140
+ L.append(f"| {name} | {len(db['tables'])} | {total:,} | {len(db['views'])} | {len(db['modules'])} |")
141
+ L += ["", f"Linked servers: {len(inv.get('linked_servers', []))}. SQL Server Agent jobs: {len({j['job_name'] for j in inv.get('agent_jobs', [])})}.", ""]
142
+ by_sev: dict[str, int] = {}
143
+ for r in risks:
144
+ by_sev[r["severity"]] = by_sev.get(r["severity"], 0) + 1
145
+ L += ["## Upgrade risks", "", ", ".join(f"{k}: {v}" for k, v in sorted(by_sev.items())) or "None found.", ""]
146
+ if risks:
147
+ L += ["| Severity | Database | Object | Finding | Source |", "|---|---|---|---|---|"]
148
+ for r in risks[:60]:
149
+ L.append(f"| {r['severity']} | {r['database']} | {r['object']} | {r['title']}. {r['detail'][:140]} | [doc]({r['doc']}) |")
150
+ if len(risks) > 60:
151
+ L.append(f"\n{len(risks) - 60} more in risks.csv.")
152
+ if inv["warnings"]:
153
+ L += ["", "## Not collected", ""] + [f"- {w}" for w in inv["warnings"]]
154
+ return "\n".join(L) + "\n"
155
+
156
+
157
+ RISK_COLUMNS = ["severity", "category", "database", "object", "title", "detail", "azure_sql_database_only", "doc"]
158
+ INV_COLUMNS = ["database", "kind", "schema", "name", "rows", "columns", "indexes", "detail"]
159
+
160
+
161
+ def write_inventory(out: Path, inv: dict[str, Any], risks: list[dict[str, Any]], formats: set[str], include_code: bool) -> list[str]:
162
+ written = []
163
+ modules = {db: [{"schema": m["schema"], "name": m["name"], "type": m["type"], "definition": m["definition"]} for m in o["modules"]]
164
+ for db, o in inv["objects"].items()}
165
+ slim = json.loads(json.dumps(inv, default=_default))
166
+ for o in slim["objects"].values():
167
+ for m in o["modules"]:
168
+ m.pop("definition", None)
169
+ slim.pop("agent_jobs", None)
170
+ slim["agent_jobs"] = [{k: v for k, v in j.items() if k != "command"} for j in inv.get("agent_jobs", [])]
171
+ slim["linked_servers"] = [{k: v for k, v in ls.items() if k != "data_source"} for ls in inv.get("linked_servers", [])]
172
+ if "json" in formats:
173
+ write_json(out / "inventory.json", slim)
174
+ written.append("inventory.json")
175
+ if include_code:
176
+ write_json(out / "modules.json", modules)
177
+ written.append("modules.json (object code; may contain secrets, handle with care)")
178
+ if "md" in formats:
179
+ (out / "inventory.md").write_text(render_inventory_md(inv, risks), encoding="utf-8")
180
+ write_csv(out / "risks.csv", risks, RISK_COLUMNS)
181
+ written += ["inventory.md", "risks.csv"]
182
+ if "xlsx" in formats:
183
+ update_sheet(out, "Inventory", inventory_rows(inv), INV_COLUMNS)
184
+ update_sheet(out, "Risks", risks, RISK_COLUMNS)
185
+ written.append(f"{WORKBOOK} (Inventory, Risks)")
186
+ write_json(out / "risks.json", risks) # read by `report`
187
+ return written
188
+
189
+
190
+ # ---- ssrs -----------------------------------------------------------------------------------
191
+ REPORT_COLUMNS = ["path", "name", "folder", "classification", "reasons", "layout_hint", "last_run", "runs_90", "runs_365",
192
+ "distinct_users", "avg_ms", "errors", "dataset_count", "parameter_count", "tables", "procedures", "ref_confidence",
193
+ "data_source_servers", "subreports", "drillthrough", "custom_code", "custom_assemblies", "subscriptions",
194
+ "expressions", "complex_expressions", "embedded_credentials", "rdl_version", "linked_report_of", "parse_error"]
195
+
196
+
197
+ def render_ssrs_summary_md(res: dict[str, Any]) -> str:
198
+ c = res["classification_counts"]
199
+ u = res["usage"]
200
+ reps = res["reports"]
201
+ used = sum(1 for r in reps if (r["runs_365"] or 0) > 0)
202
+ L = ["# SSRS report inventory: summary", "",
203
+ f"Reports found: **{res['report_count']}** | Move: **{c.get('move', 0)}** | Review: **{c.get('review', 0)}** | Retire: **{c.get('retire', 0)}**", ""]
204
+ if u["source"]:
205
+ L += [f"Usage source: {u['source']}. The log reaches back {u['log_window_days']} days (oldest entry {u['oldest']}).",
206
+ f"Reports run in the last 365 days: {used}. Not run: {res['report_count'] - used}.", ""]
207
+ if (u["log_window_days"] or 0) < 365:
208
+ L += ["> **The usage log is shorter than a year.** SSRS keeps 60 days of log by default. A report that runs once a quarter or once a year can look unused. "
209
+ "No report is called retire until the log covers 365 days.", ""]
210
+ else:
211
+ L += ["No usage data was supplied, so no report is called retire. Supply the execution log (--reportserver-db) or a CSV (--usage-csv).", ""]
212
+ if not res["subscriptions_known"]:
213
+ L += ["Subscriptions were not read (folder mode without subscriptions.csv).", ""]
214
+ L += ["## What to do first", "",
215
+ f"1. **Retire ({c.get('retire', 0)}).** Confirm with the owners, then leave these out of the move.",
216
+ f"2. **Move ({c.get('move', 0)}).** Plain reports. Start here; check the results with `validate`.",
217
+ f"3. **Review ({c.get('review', 0)}).** Each has a reason below. Someone needs to look at it before it is moved.", ""]
218
+ for label in ("retire", "review", "move"):
219
+ group = [r for r in reps if r["classification"] == label]
220
+ if not group:
221
+ continue
222
+ L += [f"## {label.capitalize()} ({len(group)})", "", "| Report | Runs (365 days) | Why |", "|---|---|---|"]
223
+ for r in group:
224
+ L.append(f"| {r['path']} | {r['runs_365'] if r['runs_365'] is not None else 'n/a'} | {'; '.join(r['reasons'])} |")
225
+ L.append("")
226
+ if res["table_usage"]:
227
+ L += ["## Tables used most by reports", "", "| Table | Reports |", "|---|---|"] + [f"| {t} | {n} |" for t, n in res["table_usage"][:15]] + [""]
228
+ L += ["Table lists come from parsing report SQL and are best effort. See the confidence column in reports.csv.", ""]
229
+ return "\n".join(L)
230
+
231
+
232
+ def render_report_detail_md(r: dict[str, Any]) -> str:
233
+ L = [f"# {r['path']}", "",
234
+ f"- Classification: **{r['classification']}**. {'; '.join(r['reasons'])}",
235
+ f"- Layout hint: {r['layout_hint']} (a hint only)",
236
+ f"- RDL version: {r['rdl_version']}",
237
+ f"- Runs: {r['runs_90']} in 90 days, {r['runs_365']} in 365 days, last run {r['last_run']}, distinct users {r['distinct_users']}"
238
+ if r["usage_known"] else "- Usage: not supplied", ""]
239
+ if r["data_sources"]:
240
+ L += ["## Data sources", ""]
241
+ for s in r["data_sources"]:
242
+ L.append(f"- {s['name']}: {s['provider'] or (s['shared_reference'] and 'shared ' + s['shared_reference']) or '?'}, server {s['server']}, database {s['database']}"
243
+ + (" (**connection string contains a password**)" if s["embedded_credentials"] else ""))
244
+ L.append("")
245
+ if r["parameters"]:
246
+ L += ["## Parameters", "", "| Name | Type | Default | Multi-value |", "|---|---|---|---|"]
247
+ L += [f"| {p['name']} | {p['type']} | {p['default']} | {p['multi_value']} |" for p in r["parameters"]]
248
+ L.append("")
249
+ for d in r["datasets"]:
250
+ L += [f"## Dataset {d['name']}", "", f"- Command type: {d['command_type']}. Reference confidence: {d['confidence']}"
251
+ + (f" ({d['parse_error']})" if d["parse_error"] else ""),
252
+ f"- Tables: {', '.join(d['tables']) or 'none found'}", f"- Procedures: {', '.join(d['procedures']) or 'none'}", "", "```sql", d["command_text"].strip(), "```", ""]
253
+ if r["subreports"]:
254
+ L += ["Subreports: " + ", ".join(r["subreports"]), ""]
255
+ return "\n".join(L)
256
+
257
+
258
+ def write_ssrs(out: Path, res: dict[str, Any], formats: set[str]) -> list[str]:
259
+ written = []
260
+ write_json(out / "reports.json", res) # read by validate and report
261
+ written.append("reports.json")
262
+ if "md" in formats:
263
+ write_csv(out / "reports.csv", res["reports"], REPORT_COLUMNS)
264
+ (out / "summary.md").write_text(render_ssrs_summary_md(res), encoding="utf-8")
265
+ d = out / "report-details"
266
+ d.mkdir(exist_ok=True)
267
+ for r in res["reports"]:
268
+ (d / f"{safe_filename(r['path'])}.md").write_text(render_report_detail_md(r), encoding="utf-8")
269
+ written += ["reports.csv", "summary.md", f"report-details/ ({len(res['reports'])} files)"]
270
+ if "xlsx" in formats:
271
+ update_sheet(out, "Reports", res["reports"], REPORT_COLUMNS)
272
+ written.append(f"{WORKBOOK} (Reports)")
273
+ return written
274
+
275
+
276
+ # ---- validate -------------------------------------------------------------------------------
277
+ VALIDATION_COLUMNS = ["report", "dataset", "status", "reason", "source_rows", "target_rows", "source_ms", "target_ms", "differences"]
278
+
279
+
280
+ def write_validation(out: Path, rows: list[dict[str, Any]], formats: set[str]) -> list[str]:
281
+ write_json(out / "validation.json", rows)
282
+ written = ["validation.json"]
283
+ if "md" in formats:
284
+ write_csv(out / "validation.csv", rows, VALIDATION_COLUMNS)
285
+ counts: dict[str, int] = {}
286
+ for r in rows:
287
+ counts[r["status"]] = counts.get(r["status"], 0) + 1
288
+ L = ["# Validation", "", "Compared on row counts and totals of numeric columns. No row values are stored.", "",
289
+ ", ".join(f"{k}: {v}" for k, v in sorted(counts.items())) or "Nothing was compared.", "",
290
+ "| Report | Dataset | Status | Source rows | Target rows | Note |", "|---|---|---|---|---|---|"]
291
+ for r in rows:
292
+ L.append(f"| {r['report']} | {r['dataset']} | {r['status']} | {r['source_rows'] if r['source_rows'] is not None else ''} | "
293
+ f"{r['target_rows'] if r['target_rows'] is not None else ''} | {(r['differences'] or r['reason'])[:160]} |")
294
+ (out / "validation.md").write_text("\n".join(L) + "\n", encoding="utf-8")
295
+ written += ["validation.csv", "validation.md"]
296
+ if "xlsx" in formats:
297
+ update_sheet(out, "Validation", rows, VALIDATION_COLUMNS)
298
+ written.append(f"{WORKBOOK} (Validation)")
299
+ return written