spring-runtime-mcp 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.
- spring_runtime_mcp/__init__.py +3 -0
- spring_runtime_mcp/adapters/__init__.py +1 -0
- spring_runtime_mcp/adapters/beans.py +159 -0
- spring_runtime_mcp/adapters/conditions.py +333 -0
- spring_runtime_mcp/adapters/config.py +295 -0
- spring_runtime_mcp/adapters/drift.py +790 -0
- spring_runtime_mcp/adapters/health.py +164 -0
- spring_runtime_mcp/adapters/mapping_predicates.py +201 -0
- spring_runtime_mcp/adapters/mappings.py +294 -0
- spring_runtime_mcp/adapters/migrations.py +373 -0
- spring_runtime_mcp/adapters/openapi.py +683 -0
- spring_runtime_mcp/adapters/source_catalog.py +119 -0
- spring_runtime_mcp/adapters/startup.py +202 -0
- spring_runtime_mcp/analyzers/__init__.py +1 -0
- spring_runtime_mcp/analyzers/bean_graph.py +172 -0
- spring_runtime_mcp/analyzers/condition_failures.py +359 -0
- spring_runtime_mcp/cli.py +248 -0
- spring_runtime_mcp/contracts/__init__.py +1 -0
- spring_runtime_mcp/contracts/definitions.py +425 -0
- spring_runtime_mcp/diagnostics.py +254 -0
- spring_runtime_mcp/models/__init__.py +1 -0
- spring_runtime_mcp/models/bean_graph.py +77 -0
- spring_runtime_mcp/models/common.py +99 -0
- spring_runtime_mcp/models/conditions.py +56 -0
- spring_runtime_mcp/models/queries.py +140 -0
- spring_runtime_mcp/models/source_catalog.py +35 -0
- spring_runtime_mcp/models/timestamps.py +71 -0
- spring_runtime_mcp/policy/__init__.py +1 -0
- spring_runtime_mcp/policy/configuration.py +32 -0
- spring_runtime_mcp/policy/output_guard.py +117 -0
- spring_runtime_mcp/policy/sanitization.py +616 -0
- spring_runtime_mcp/py.typed +0 -0
- spring_runtime_mcp/replay.py +220 -0
- spring_runtime_mcp/replay_data/bean_graph/README.md +26 -0
- spring_runtime_mcp/replay_data/bean_graph/factory-method/conditions.json +43 -0
- spring_runtime_mcp/replay_data/bean_graph/factory-method/source-catalog.json +54 -0
- spring_runtime_mcp/replay_data/bean_graph/nested-conditions/beans.json +99 -0
- spring_runtime_mcp/replay_data/bean_graph/nested-conditions/conditions.json +25 -0
- spring_runtime_mcp/replay_data/bean_graph/parent-child/beans.json +92 -0
- spring_runtime_mcp/replay_data/bean_graph/shape-metadata.json +32 -0
- spring_runtime_mcp/replay_data/bean_graph/stripe-missing/conditions.json +43 -0
- spring_runtime_mcp/replay_data/bean_graph/stripe-missing/source-catalog.json +54 -0
- spring_runtime_mcp/replay_data/conformance/README.md +41 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/beans.json +92 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/conditions.json +52 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/configprops.json +20 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/env.json +16 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/flyway.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/health-v2.json +14 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/health-v3.json +38 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/liquibase.json +30 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/mappings.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/openapi.json +54 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/source-catalog.json +90 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/startup.json +45 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/beans.json +92 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/conditions.json +52 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/configprops.json +20 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/env.json +16 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/flyway.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/health-v3.json +38 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/liquibase.json +30 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/mappings.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/openapi.json +54 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/source-catalog.json +90 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/startup.json +45 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/beans.json +92 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/conditions.json +52 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/configprops.json +20 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/env.json +16 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/flyway.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/health-v3.json +38 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/liquibase.json +30 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/mappings.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/openapi.json +54 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/servlet-namespace-comparison.json +4 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/source-catalog.json +90 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/startup.json +45 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_analyze_startup.json +62 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_check_migrations.json +63 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_detect_drift.json +64 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_diagnose_health.json +59 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_inspect_beans.json +56 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_inspect_config.json +62 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_search_openapi.json +80 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_verify_mappings.json +58 -0
- spring_runtime_mcp/replay_data/conformance/faults/cases.json +63 -0
- spring_runtime_mcp/replay_data/conformance/faults/health-hidden.expected.json +54 -0
- spring_runtime_mcp/replay_data/conformance/faults/health-hidden.json +3 -0
- spring_runtime_mcp/replay_data/conformance/large/beans-2500.json +22506 -0
- spring_runtime_mcp/replay_data/conformance/manifest.json +46 -0
- spring_runtime_mcp/schemas/tools-contract.json +1902 -0
- spring_runtime_mcp/server.py +322 -0
- spring_runtime_mcp/services.py +44 -0
- spring_runtime_mcp/snapshots.py +340 -0
- spring_runtime_mcp/tools/__init__.py +1 -0
- spring_runtime_mcp/tools/analyze_startup.py +79 -0
- spring_runtime_mcp/tools/check_migrations.py +99 -0
- spring_runtime_mcp/tools/detect_drift.py +323 -0
- spring_runtime_mcp/tools/diagnose_health.py +88 -0
- spring_runtime_mcp/tools/inspect_beans.py +479 -0
- spring_runtime_mcp/tools/inspect_config.py +102 -0
- spring_runtime_mcp/tools/registry.py +48 -0
- spring_runtime_mcp/tools/search_openapi.py +79 -0
- spring_runtime_mcp/tools/support.py +166 -0
- spring_runtime_mcp/tools/verify_mappings.py +62 -0
- spring_runtime_mcp/transport/__init__.py +1 -0
- spring_runtime_mcp/transport/actuator_http.py +459 -0
- spring_runtime_mcp/transport/bounded_json.py +144 -0
- spring_runtime_mcp/transport/replay_files.py +71 -0
- spring_runtime_mcp/transport/response_gate.py +186 -0
- spring_runtime_mcp/transport/responses.py +82 -0
- spring_runtime_mcp/transport/stdio.py +44 -0
- spring_runtime_mcp-0.2.0.dist-info/METADATA +320 -0
- spring_runtime_mcp-0.2.0.dist-info/RECORD +118 -0
- spring_runtime_mcp-0.2.0.dist-info/WHEEL +4 -0
- spring_runtime_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- spring_runtime_mcp-0.2.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""Normalize Flyway and Liquibase actuator beans without executing migrations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..models.common import AnalysisBudget, AnalysisLimit
|
|
10
|
+
|
|
11
|
+
from ..models.queries import MigrationQuery
|
|
12
|
+
|
|
13
|
+
_FLYWAY = {
|
|
14
|
+
"SUCCESS": "APPLIED",
|
|
15
|
+
"FAILED": "FAILED",
|
|
16
|
+
"PENDING": "PENDING",
|
|
17
|
+
"OUT_OF_ORDER": "OUT_OF_ORDER",
|
|
18
|
+
"MISSING_FAILED": "MISSING_FAILED",
|
|
19
|
+
"MISSING_SUCCESS": "APPLIED",
|
|
20
|
+
"FUTURE_SUCCESS": "APPLIED",
|
|
21
|
+
"FUTURE_FAILED": "FAILED",
|
|
22
|
+
}
|
|
23
|
+
_LIQUIBASE = {
|
|
24
|
+
"EXECUTED": "APPLIED",
|
|
25
|
+
"FAILED": "FAILED",
|
|
26
|
+
"MARK_RAN": "APPLIED",
|
|
27
|
+
"RERAN": "APPLIED",
|
|
28
|
+
"SKIPPED": "PENDING",
|
|
29
|
+
}
|
|
30
|
+
_ALGORITHM = re.compile(r"(\d+):")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _checksum(value: Any) -> tuple[str | None, str]:
|
|
34
|
+
if isinstance(value, bool) or value is None:
|
|
35
|
+
return None, "absent"
|
|
36
|
+
if isinstance(value, int):
|
|
37
|
+
return str(value), "number"
|
|
38
|
+
if isinstance(value, str) and value:
|
|
39
|
+
return value[:128], "string"
|
|
40
|
+
return None, "absent"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _state(raw: str | None, table: dict[str, str]) -> tuple[str, str]:
|
|
44
|
+
if raw is None:
|
|
45
|
+
return "UNKNOWN", "unknown"
|
|
46
|
+
mapped = table.get(raw)
|
|
47
|
+
if mapped is None:
|
|
48
|
+
return "UNKNOWN", "reported"
|
|
49
|
+
return mapped, "reported"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _keep(query: MigrationQuery, row: dict[str, Any]) -> bool:
|
|
53
|
+
if query.context_id and row["contextId"] != query.context_id:
|
|
54
|
+
return False
|
|
55
|
+
if query.migration_bean and row["migrationBean"] != query.migration_bean:
|
|
56
|
+
return False
|
|
57
|
+
if query.states and row["state"] not in query.states:
|
|
58
|
+
return False
|
|
59
|
+
return not (query.version is not None and row["version"] != query.version)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_ATTENTION_RANK = {"FAILED": 0, "MISSING_FAILED": 1, "OUT_OF_ORDER": 2, "PENDING": 3}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _migration_order(row: dict[str, Any]) -> tuple[int, int, int, str]:
|
|
66
|
+
"""Attention state first, then engine order, then stable identity."""
|
|
67
|
+
order = row["orderExecuted"]
|
|
68
|
+
known = isinstance(order, int)
|
|
69
|
+
return (
|
|
70
|
+
_ATTENTION_RANK.get(row["state"], 4),
|
|
71
|
+
0 if known else 1,
|
|
72
|
+
order if known else 0,
|
|
73
|
+
row["identity"],
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _from_flyway(body: Any) -> tuple[list[dict[str, Any]], list[str]]:
|
|
78
|
+
if not isinstance(body, dict) or not isinstance(body.get("contexts"), dict):
|
|
79
|
+
raise ValueError("INVALID_MIGRATIONS_SHAPE")
|
|
80
|
+
rows: list[dict[str, Any]] = []
|
|
81
|
+
gaps: set[str] = set()
|
|
82
|
+
budget = AnalysisBudget()
|
|
83
|
+
for context_name in sorted(body["contexts"]):
|
|
84
|
+
context = body["contexts"][context_name]
|
|
85
|
+
if not isinstance(context, dict) or not isinstance(
|
|
86
|
+
context.get("flywayBeans"), dict
|
|
87
|
+
):
|
|
88
|
+
gaps.add("FLYWAY_BEANS_UNOBSERVED")
|
|
89
|
+
continue
|
|
90
|
+
parent = (
|
|
91
|
+
context.get("parentId")
|
|
92
|
+
if isinstance(context.get("parentId"), str)
|
|
93
|
+
else None
|
|
94
|
+
)
|
|
95
|
+
for bean_name in sorted(context["flywayBeans"]):
|
|
96
|
+
bean = context["flywayBeans"][bean_name]
|
|
97
|
+
migrations = bean.get("migrations") if isinstance(bean, dict) else None
|
|
98
|
+
if not isinstance(migrations, list):
|
|
99
|
+
gaps.add("FLYWAY_MIGRATIONS_UNOBSERVED")
|
|
100
|
+
continue
|
|
101
|
+
for migration in migrations:
|
|
102
|
+
budget.consume()
|
|
103
|
+
if not isinstance(migration, dict):
|
|
104
|
+
gaps.add("MIGRATION_RECORD_UNREADABLE")
|
|
105
|
+
continue
|
|
106
|
+
raw = (
|
|
107
|
+
migration.get("state")
|
|
108
|
+
if isinstance(migration.get("state"), str)
|
|
109
|
+
else None
|
|
110
|
+
)
|
|
111
|
+
state, evidence = _state(raw, _FLYWAY)
|
|
112
|
+
version = (
|
|
113
|
+
migration.get("version")
|
|
114
|
+
if isinstance(migration.get("version"), str)
|
|
115
|
+
else None
|
|
116
|
+
)
|
|
117
|
+
script = (
|
|
118
|
+
migration.get("script")
|
|
119
|
+
if isinstance(migration.get("script"), str)
|
|
120
|
+
else None
|
|
121
|
+
)
|
|
122
|
+
checksum, checksum_type = _checksum(migration.get("checksum"))
|
|
123
|
+
rows.append(
|
|
124
|
+
{
|
|
125
|
+
"endpointId": "flyway",
|
|
126
|
+
"contextId": context_name
|
|
127
|
+
if isinstance(context_name, str)
|
|
128
|
+
else None,
|
|
129
|
+
"parentContextId": parent,
|
|
130
|
+
"engine": "flyway",
|
|
131
|
+
"migrationBean": str(bean_name)[:256],
|
|
132
|
+
"identity": f"{bean_name}:{version or script or 'unversioned'}"[
|
|
133
|
+
:256
|
|
134
|
+
],
|
|
135
|
+
"script": script[:256] if script else None,
|
|
136
|
+
"changeLog": None,
|
|
137
|
+
"changeSetId": None,
|
|
138
|
+
"author": None,
|
|
139
|
+
"version": version[:128] if version else None,
|
|
140
|
+
"description": None,
|
|
141
|
+
"checksum": checksum,
|
|
142
|
+
"checksumType": checksum_type,
|
|
143
|
+
"checksumAlgorithm": None,
|
|
144
|
+
"executionTimeMs": migration.get("executionTime")
|
|
145
|
+
if isinstance(migration.get("executionTime"), (int, float))
|
|
146
|
+
and not isinstance(migration.get("executionTime"), bool)
|
|
147
|
+
and migration["executionTime"] >= 0
|
|
148
|
+
else None,
|
|
149
|
+
"orderExecuted": migration.get("installedRank")
|
|
150
|
+
if type(migration.get("installedRank")) is int
|
|
151
|
+
and migration["installedRank"] >= 0
|
|
152
|
+
else None,
|
|
153
|
+
"executedAt": migration.get("installedOn")
|
|
154
|
+
if isinstance(migration.get("installedOn"), str)
|
|
155
|
+
else None,
|
|
156
|
+
"rawState": raw[:64] if raw else None,
|
|
157
|
+
"state": state,
|
|
158
|
+
"stateEvidence": evidence,
|
|
159
|
+
"attentionRequired": state
|
|
160
|
+
in {
|
|
161
|
+
"FAILED",
|
|
162
|
+
"PENDING",
|
|
163
|
+
"OUT_OF_ORDER",
|
|
164
|
+
"MISSING_FAILED",
|
|
165
|
+
"UNKNOWN",
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
return rows, sorted(gaps)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _from_liquibase(body: Any) -> tuple[list[dict[str, Any]], list[str]]:
|
|
173
|
+
if not isinstance(body, dict) or not isinstance(body.get("contexts"), dict):
|
|
174
|
+
raise ValueError("INVALID_MIGRATIONS_SHAPE")
|
|
175
|
+
rows: list[dict[str, Any]] = []
|
|
176
|
+
gaps: set[str] = set()
|
|
177
|
+
budget = AnalysisBudget()
|
|
178
|
+
for context_name in sorted(body["contexts"]):
|
|
179
|
+
context = body["contexts"][context_name]
|
|
180
|
+
if not isinstance(context, dict) or not isinstance(
|
|
181
|
+
context.get("liquibaseBeans"), dict
|
|
182
|
+
):
|
|
183
|
+
gaps.add("LIQUIBASE_BEANS_UNOBSERVED")
|
|
184
|
+
continue
|
|
185
|
+
parent = (
|
|
186
|
+
context.get("parentId")
|
|
187
|
+
if isinstance(context.get("parentId"), str)
|
|
188
|
+
else None
|
|
189
|
+
)
|
|
190
|
+
for bean_name in sorted(context["liquibaseBeans"]):
|
|
191
|
+
bean = context["liquibaseBeans"][bean_name]
|
|
192
|
+
changes = bean.get("changeSets") if isinstance(bean, dict) else None
|
|
193
|
+
if not isinstance(changes, list):
|
|
194
|
+
gaps.add("LIQUIBASE_CHANGESETS_UNOBSERVED")
|
|
195
|
+
continue
|
|
196
|
+
for change in changes:
|
|
197
|
+
budget.consume()
|
|
198
|
+
if not isinstance(change, dict):
|
|
199
|
+
gaps.add("MIGRATION_RECORD_UNREADABLE")
|
|
200
|
+
continue
|
|
201
|
+
raw = (
|
|
202
|
+
change.get("execType")
|
|
203
|
+
if isinstance(change.get("execType"), str)
|
|
204
|
+
else None
|
|
205
|
+
)
|
|
206
|
+
state, evidence = _state(raw, _LIQUIBASE)
|
|
207
|
+
raw_id = change.get("id")
|
|
208
|
+
change_id = raw_id if isinstance(raw_id, str) else "unidentified"
|
|
209
|
+
change_log = (
|
|
210
|
+
change.get("changeLog")
|
|
211
|
+
if isinstance(change.get("changeLog"), str)
|
|
212
|
+
else None
|
|
213
|
+
)
|
|
214
|
+
author = (
|
|
215
|
+
change.get("author")
|
|
216
|
+
if isinstance(change.get("author"), str)
|
|
217
|
+
else None
|
|
218
|
+
)
|
|
219
|
+
if change_log is None or author is None:
|
|
220
|
+
gaps.add("MIGRATION_IDENTITY_INCOMPLETE")
|
|
221
|
+
identity = ":".join(
|
|
222
|
+
(change_log or "unknown", change_id, author or "unknown")
|
|
223
|
+
)
|
|
224
|
+
checksum, checksum_type = _checksum(change.get("checksum"))
|
|
225
|
+
algorithm = None
|
|
226
|
+
if checksum and (matched := _ALGORITHM.match(checksum)):
|
|
227
|
+
algorithm = matched.group(1)
|
|
228
|
+
rows.append(
|
|
229
|
+
{
|
|
230
|
+
"endpointId": "liquibase",
|
|
231
|
+
"contextId": context_name
|
|
232
|
+
if isinstance(context_name, str)
|
|
233
|
+
else None,
|
|
234
|
+
"parentContextId": parent,
|
|
235
|
+
"engine": "liquibase",
|
|
236
|
+
"migrationBean": str(bean_name)[:256],
|
|
237
|
+
"identity": identity[:256],
|
|
238
|
+
"script": None,
|
|
239
|
+
"changeLog": change_log,
|
|
240
|
+
"changeSetId": change_id[:256],
|
|
241
|
+
"author": author,
|
|
242
|
+
"version": None,
|
|
243
|
+
"description": None,
|
|
244
|
+
"checksum": checksum,
|
|
245
|
+
"checksumType": checksum_type,
|
|
246
|
+
"checksumAlgorithm": algorithm,
|
|
247
|
+
"executionTimeMs": None,
|
|
248
|
+
"orderExecuted": change.get("orderExecuted")
|
|
249
|
+
if type(change.get("orderExecuted")) is int
|
|
250
|
+
and change["orderExecuted"] >= 0
|
|
251
|
+
else None,
|
|
252
|
+
"executedAt": change.get("dateExecuted")
|
|
253
|
+
if isinstance(change.get("dateExecuted"), str)
|
|
254
|
+
else None,
|
|
255
|
+
"rawState": raw[:64] if raw else None,
|
|
256
|
+
"state": state,
|
|
257
|
+
"stateEvidence": evidence,
|
|
258
|
+
"attentionRequired": state
|
|
259
|
+
in {
|
|
260
|
+
"FAILED",
|
|
261
|
+
"PENDING",
|
|
262
|
+
"OUT_OF_ORDER",
|
|
263
|
+
"MISSING_FAILED",
|
|
264
|
+
"UNKNOWN",
|
|
265
|
+
},
|
|
266
|
+
}
|
|
267
|
+
)
|
|
268
|
+
return rows, sorted(gaps)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def apply_expected(
|
|
272
|
+
rows: list[dict[str, Any]], query: MigrationQuery
|
|
273
|
+
) -> tuple[list[dict[str, Any]], list[str], str]:
|
|
274
|
+
if not query.expected:
|
|
275
|
+
return rows, [], "unknown"
|
|
276
|
+
observed = {
|
|
277
|
+
(row["engine"], row["contextId"], row["migrationBean"], row["identity"]): row
|
|
278
|
+
for row in rows
|
|
279
|
+
}
|
|
280
|
+
gaps: list[str] = []
|
|
281
|
+
for expected in query.expected:
|
|
282
|
+
key = (
|
|
283
|
+
expected.engine,
|
|
284
|
+
expected.context_id,
|
|
285
|
+
expected.migration_bean,
|
|
286
|
+
expected.identity,
|
|
287
|
+
)
|
|
288
|
+
current = observed.get(key)
|
|
289
|
+
if current is None and query.expected_set_complete:
|
|
290
|
+
rows.append(
|
|
291
|
+
{
|
|
292
|
+
"endpointId": expected.engine,
|
|
293
|
+
"contextId": expected.context_id,
|
|
294
|
+
"parentContextId": None,
|
|
295
|
+
"engine": expected.engine,
|
|
296
|
+
"migrationBean": expected.migration_bean,
|
|
297
|
+
"identity": expected.identity,
|
|
298
|
+
"script": None,
|
|
299
|
+
"changeLog": None,
|
|
300
|
+
"changeSetId": None,
|
|
301
|
+
"author": None,
|
|
302
|
+
"version": expected.version,
|
|
303
|
+
"description": None,
|
|
304
|
+
"checksum": expected.checksum,
|
|
305
|
+
"checksumType": "string" if expected.checksum else "absent",
|
|
306
|
+
"checksumAlgorithm": None,
|
|
307
|
+
"executionTimeMs": None,
|
|
308
|
+
"orderExecuted": None,
|
|
309
|
+
"executedAt": None,
|
|
310
|
+
"rawState": None,
|
|
311
|
+
"state": "PENDING",
|
|
312
|
+
"stateEvidence": "baselineInferred",
|
|
313
|
+
"attentionRequired": True,
|
|
314
|
+
}
|
|
315
|
+
)
|
|
316
|
+
continue
|
|
317
|
+
if current and expected.checksum:
|
|
318
|
+
if current["checksum"] is None:
|
|
319
|
+
gaps.append("CHECKSUM_UNOBSERVED")
|
|
320
|
+
continue
|
|
321
|
+
if expected.engine == "liquibase":
|
|
322
|
+
baseline_algorithm = _ALGORITHM.match(expected.checksum)
|
|
323
|
+
actual_algorithm = current["checksumAlgorithm"]
|
|
324
|
+
if baseline_algorithm is None or actual_algorithm is None:
|
|
325
|
+
gaps.append("CHECKSUM_ALGORITHM_UNOBSERVED")
|
|
326
|
+
continue
|
|
327
|
+
if baseline_algorithm.group(1) != actual_algorithm:
|
|
328
|
+
gaps.append("CHECKSUM_ALGORITHM_INCOMPATIBLE")
|
|
329
|
+
continue
|
|
330
|
+
if current["checksum"] != expected.checksum:
|
|
331
|
+
gaps.append("CHECKSUM_MISMATCH")
|
|
332
|
+
current["attentionRequired"] = True
|
|
333
|
+
return rows, gaps, "baselineInferred"
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def normalize_migrations(
|
|
337
|
+
flyway: Any | None, liquibase: Any | None, query: MigrationQuery
|
|
338
|
+
) -> tuple[list[dict[str, Any]], list[str], str]:
|
|
339
|
+
rows: list[dict[str, Any]] = []
|
|
340
|
+
gaps: list[str] = []
|
|
341
|
+
complete_engines: set[str] = set()
|
|
342
|
+
for engine, body, parser in (
|
|
343
|
+
("flyway", flyway, _from_flyway),
|
|
344
|
+
("liquibase", liquibase, _from_liquibase),
|
|
345
|
+
):
|
|
346
|
+
if body is None:
|
|
347
|
+
continue
|
|
348
|
+
try:
|
|
349
|
+
parsed, parsed_gaps = parser(body)
|
|
350
|
+
except AnalysisLimit:
|
|
351
|
+
# Budget exhaustion is not malformed upstream evidence.
|
|
352
|
+
gaps.append("GRAPH_LIMIT_REACHED")
|
|
353
|
+
continue
|
|
354
|
+
except (ValueError, TypeError, KeyError):
|
|
355
|
+
gaps.append(engine.upper() + "_EVIDENCE_INVALID")
|
|
356
|
+
continue
|
|
357
|
+
rows.extend(parsed)
|
|
358
|
+
gaps.extend(parsed_gaps)
|
|
359
|
+
if not parsed_gaps:
|
|
360
|
+
complete_engines.add(engine)
|
|
361
|
+
comparison = replace(
|
|
362
|
+
query,
|
|
363
|
+
expected=tuple(e for e in query.expected if e.engine in complete_engines),
|
|
364
|
+
)
|
|
365
|
+
rows, expected_gaps, visibility = apply_expected(rows, comparison)
|
|
366
|
+
gaps.extend(expected_gaps)
|
|
367
|
+
kept = [row for row in rows if _keep(query, row)]
|
|
368
|
+
kept.sort(key=_migration_order)
|
|
369
|
+
items = []
|
|
370
|
+
for index, row in enumerate(kept, start=1):
|
|
371
|
+
row["id"] = f"migration_{index:06d}"
|
|
372
|
+
items.append(row)
|
|
373
|
+
return items, sorted(set(gaps)), visibility
|