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,790 @@
|
|
|
1
|
+
"""Compare caller expectations with one runtime snapshot.
|
|
2
|
+
|
|
3
|
+
Omitted dimensions are not read and not compared. An empty expectation is an
|
|
4
|
+
explicit empty scope. Unexpected runtime objects are reported only when the
|
|
5
|
+
caller marks that scope complete and the corresponding observation is complete.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ..adapters.beans import normalize_beans
|
|
14
|
+
from ..adapters.mappings import normalize_mappings
|
|
15
|
+
from ..adapters.migrations import normalize_migrations
|
|
16
|
+
from ..analyzers.bean_graph import (
|
|
17
|
+
build_dependency_graph,
|
|
18
|
+
resolve_bean_reference,
|
|
19
|
+
slice_dependencies,
|
|
20
|
+
visible_beans,
|
|
21
|
+
)
|
|
22
|
+
from ..analyzers.condition_failures import is_assignable
|
|
23
|
+
from ..models.bean_graph import Bean, BeanGraph, BeanKey, ContextKey
|
|
24
|
+
from ..models.common import AnalysisBudget, AnalysisLimit, ParsedObservation
|
|
25
|
+
from ..models.queries import (
|
|
26
|
+
DriftQuery,
|
|
27
|
+
ExpectedBean,
|
|
28
|
+
ExpectedRoute,
|
|
29
|
+
MigrationQuery,
|
|
30
|
+
)
|
|
31
|
+
from ..models.source_catalog import SourceCatalog
|
|
32
|
+
|
|
33
|
+
_IDENTITY = re.compile(r"[A-Za-z0-9_$][A-Za-z0-9_.$#/@:+-]*\Z")
|
|
34
|
+
_FACT = re.compile(r"[A-Za-z0-9_.$#/@:+-]{1,256}\Z")
|
|
35
|
+
_PROFILE = re.compile(r"[A-Za-z0-9_.-]{1,128}\Z")
|
|
36
|
+
_BRACE = re.compile(r"\{([A-Za-z0-9_$]+)\}")
|
|
37
|
+
_DIM = {"beans": 0, "routes": 1, "migrations": 2}
|
|
38
|
+
_FLYWAY_GAPS = frozenset(
|
|
39
|
+
{
|
|
40
|
+
"FLYWAY_EVIDENCE_INVALID",
|
|
41
|
+
"FLYWAY_BEANS_UNOBSERVED",
|
|
42
|
+
"FLYWAY_MIGRATIONS_UNOBSERVED",
|
|
43
|
+
"MIGRATION_RECORD_UNREADABLE",
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _public_identity(value: str) -> str | None:
|
|
49
|
+
folded = _BRACE.sub(r":\1", value)
|
|
50
|
+
if len(folded) > 256 or _IDENTITY.fullmatch(folded) is None:
|
|
51
|
+
return None
|
|
52
|
+
return folded
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _fact(value: str) -> str | None:
|
|
56
|
+
if _FACT.fullmatch(value) is None:
|
|
57
|
+
return None
|
|
58
|
+
return value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _record(
|
|
62
|
+
dimension: str,
|
|
63
|
+
identity: str,
|
|
64
|
+
context_id: str | None,
|
|
65
|
+
expected: str,
|
|
66
|
+
actual: str,
|
|
67
|
+
evidence: str,
|
|
68
|
+
affected: list[str],
|
|
69
|
+
) -> dict[str, Any] | None:
|
|
70
|
+
public = _public_identity(identity)
|
|
71
|
+
safe_expected = _fact(expected)
|
|
72
|
+
safe_actual = _fact(actual)
|
|
73
|
+
if public is None or safe_expected is None or safe_actual is None:
|
|
74
|
+
return None
|
|
75
|
+
if evidence not in ("observed", "inferred", "unknown"):
|
|
76
|
+
return None
|
|
77
|
+
return {
|
|
78
|
+
"dimension": dimension,
|
|
79
|
+
"identity": public,
|
|
80
|
+
"contextId": context_id,
|
|
81
|
+
"expected": safe_expected,
|
|
82
|
+
"actual": safe_actual,
|
|
83
|
+
"evidenceLevel": evidence,
|
|
84
|
+
"affectedDependencyIds": affected[:16],
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _sort(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
89
|
+
return sorted(
|
|
90
|
+
rows,
|
|
91
|
+
key=lambda row: (
|
|
92
|
+
_DIM[row["dimension"]],
|
|
93
|
+
row["contextId"] or "",
|
|
94
|
+
row["identity"],
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _affected(
|
|
100
|
+
graph: BeanGraph, key: BeanKey, max_depth: int, budget: AnalysisBudget
|
|
101
|
+
) -> tuple[list[str], bool]:
|
|
102
|
+
sliced = slice_dependencies(
|
|
103
|
+
graph, (key,), direction="dependents", max_depth=max_depth, budget=budget
|
|
104
|
+
)
|
|
105
|
+
found: list[str] = []
|
|
106
|
+
for edge, _depth in sliced.edges:
|
|
107
|
+
bean = graph.beans.get(edge.from_key)
|
|
108
|
+
if bean is None or bean.id in found:
|
|
109
|
+
continue
|
|
110
|
+
found.append(bean.id)
|
|
111
|
+
found.sort()
|
|
112
|
+
return found[:16], sliced.truncated or len(found) > 16
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _type_relation(
|
|
116
|
+
bean: Bean, expected: str, catalog: SourceCatalog, budget: AnalysisBudget
|
|
117
|
+
) -> str:
|
|
118
|
+
reported = bean.reported_type
|
|
119
|
+
if reported.state != "known" or not isinstance(reported.value, str):
|
|
120
|
+
return "unreported"
|
|
121
|
+
if reported.value == expected:
|
|
122
|
+
return "match"
|
|
123
|
+
try:
|
|
124
|
+
fact = is_assignable(reported.value, expected, catalog, budget=budget)
|
|
125
|
+
except AnalysisLimit:
|
|
126
|
+
return "unknown"
|
|
127
|
+
if fact.state != "known" or not isinstance(fact.value, bool):
|
|
128
|
+
return "unknown"
|
|
129
|
+
return "match" if fact.value else "mismatch"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _aliases_match(bean: Bean, aliases: tuple[str, ...]) -> bool:
|
|
133
|
+
present = set(bean.aliases)
|
|
134
|
+
return all(alias in present for alias in aliases)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _bean_records(
|
|
138
|
+
query: DriftQuery,
|
|
139
|
+
observation: ParsedObservation,
|
|
140
|
+
catalog: SourceCatalog,
|
|
141
|
+
gaps: list[str],
|
|
142
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], bool]:
|
|
143
|
+
budget = AnalysisBudget()
|
|
144
|
+
graph = build_dependency_graph(
|
|
145
|
+
normalize_beans(observation, budget=budget), budget=budget
|
|
146
|
+
)
|
|
147
|
+
incomplete = graph.coverage != "complete"
|
|
148
|
+
if incomplete:
|
|
149
|
+
gaps.extend(graph.gaps)
|
|
150
|
+
expectations = query.expected_beans or ()
|
|
151
|
+
missing: list[dict[str, Any]] = []
|
|
152
|
+
covered: set[BeanKey] = set()
|
|
153
|
+
blocked = False
|
|
154
|
+
for expected in expectations:
|
|
155
|
+
outcome, keys, blocked_one = _one_bean(
|
|
156
|
+
graph, observation, expected, catalog, budget, gaps, query.max_depth
|
|
157
|
+
)
|
|
158
|
+
blocked = blocked or blocked_one or expected.context_id is None
|
|
159
|
+
covered.update(keys)
|
|
160
|
+
if outcome is not None:
|
|
161
|
+
missing.append(outcome)
|
|
162
|
+
unexpected: list[dict[str, Any]] = []
|
|
163
|
+
scope_closed = (
|
|
164
|
+
query.expected_scope_complete
|
|
165
|
+
and not blocked
|
|
166
|
+
and graph.coverage == "complete"
|
|
167
|
+
and expectations is not None
|
|
168
|
+
)
|
|
169
|
+
if scope_closed:
|
|
170
|
+
for key, bean in sorted(graph.beans.items()):
|
|
171
|
+
if key in covered:
|
|
172
|
+
continue
|
|
173
|
+
identity = f"{key.context.context_id}:{key.canonical_name}"
|
|
174
|
+
row = _record(
|
|
175
|
+
"beans",
|
|
176
|
+
identity,
|
|
177
|
+
key.context.context_id,
|
|
178
|
+
"absent",
|
|
179
|
+
"present",
|
|
180
|
+
"observed",
|
|
181
|
+
[],
|
|
182
|
+
)
|
|
183
|
+
if row is None:
|
|
184
|
+
gaps.append("IDENTITY_NOT_SAFE")
|
|
185
|
+
incomplete = True
|
|
186
|
+
continue
|
|
187
|
+
unexpected.append(row)
|
|
188
|
+
return missing, unexpected, incomplete
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _one_bean(
|
|
192
|
+
graph: BeanGraph,
|
|
193
|
+
observation: ParsedObservation,
|
|
194
|
+
expected: ExpectedBean,
|
|
195
|
+
catalog: SourceCatalog,
|
|
196
|
+
budget: AnalysisBudget,
|
|
197
|
+
gaps: list[str],
|
|
198
|
+
max_depth: int,
|
|
199
|
+
) -> tuple[dict[str, Any] | None, set[BeanKey], bool]:
|
|
200
|
+
"""Return a diff, the runtime keys that satisfy the expectation, and whether
|
|
201
|
+
unexpected enumeration must be suppressed.
|
|
202
|
+
"""
|
|
203
|
+
keys = _candidate_beans(graph, observation, expected)
|
|
204
|
+
if keys is None:
|
|
205
|
+
gaps.append("BEAN_INDEX_INCOMPLETE")
|
|
206
|
+
return None, set(), True
|
|
207
|
+
if len(keys) > 1 and expected.name is not None:
|
|
208
|
+
satisfying = [
|
|
209
|
+
key
|
|
210
|
+
for key in keys
|
|
211
|
+
if _bean_satisfies(graph, key, expected, catalog, budget, gaps) == "match"
|
|
212
|
+
]
|
|
213
|
+
if satisfying:
|
|
214
|
+
return None, set(satisfying), False
|
|
215
|
+
gaps.append("BEAN_SCOPE_AMBIGUOUS")
|
|
216
|
+
return None, set(), True
|
|
217
|
+
if expected.name is None:
|
|
218
|
+
return _type_only(
|
|
219
|
+
graph, observation, expected, keys, catalog, budget, gaps, max_depth
|
|
220
|
+
)
|
|
221
|
+
if not keys:
|
|
222
|
+
row = _absent_bean(graph, observation, expected, gaps)
|
|
223
|
+
return row, set(), row is None
|
|
224
|
+
key = keys[0]
|
|
225
|
+
relation = _bean_satisfies(graph, key, expected, catalog, budget, gaps)
|
|
226
|
+
if relation == "match":
|
|
227
|
+
return None, {key}, False
|
|
228
|
+
if relation != "mismatch":
|
|
229
|
+
return None, set(), True
|
|
230
|
+
row, cut = _mismatch_record(graph, key, expected, catalog, budget, gaps, max_depth)
|
|
231
|
+
return row, {key}, cut
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _bean_satisfies(
|
|
235
|
+
graph: BeanGraph,
|
|
236
|
+
key: BeanKey,
|
|
237
|
+
expected: ExpectedBean,
|
|
238
|
+
catalog: SourceCatalog,
|
|
239
|
+
budget: AnalysisBudget,
|
|
240
|
+
gaps: list[str],
|
|
241
|
+
) -> str:
|
|
242
|
+
bean = graph.beans[key]
|
|
243
|
+
if expected.type_name:
|
|
244
|
+
relation = _type_relation(bean, expected.type_name, catalog, budget)
|
|
245
|
+
if relation == "unreported":
|
|
246
|
+
gaps.append("TYPE_NOT_REPORTED")
|
|
247
|
+
return "unknown"
|
|
248
|
+
if relation == "unknown":
|
|
249
|
+
gaps.append("SOURCE_TYPE_UNOBSERVED")
|
|
250
|
+
return "unknown"
|
|
251
|
+
if relation == "mismatch":
|
|
252
|
+
return "mismatch"
|
|
253
|
+
if not _aliases_match(bean, expected.aliases):
|
|
254
|
+
return "mismatch"
|
|
255
|
+
return "match"
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _candidate_beans(
|
|
259
|
+
graph: BeanGraph, observation: ParsedObservation, expected: ExpectedBean
|
|
260
|
+
) -> list[BeanKey] | None:
|
|
261
|
+
"""None means the searched hierarchy is not fully visible."""
|
|
262
|
+
if expected.name is None:
|
|
263
|
+
return _beans_by_type(graph, observation, expected)
|
|
264
|
+
starts = _start_contexts(graph, observation, expected.context_id)
|
|
265
|
+
if starts is None:
|
|
266
|
+
return None
|
|
267
|
+
found: list[BeanKey] = []
|
|
268
|
+
for start in starts:
|
|
269
|
+
resolution = resolve_bean_reference(graph, start, expected.name)
|
|
270
|
+
if resolution.state == "ambiguous":
|
|
271
|
+
return None
|
|
272
|
+
if resolution.state == "resolved":
|
|
273
|
+
found.append(resolution.candidates[0])
|
|
274
|
+
continue
|
|
275
|
+
if resolution.reason_code != "NOT_FOUND_IN_COMPLETE_VISIBLE_HIERARCHY":
|
|
276
|
+
return None
|
|
277
|
+
unique = sorted(set(found))
|
|
278
|
+
return unique
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _beans_by_type(
|
|
282
|
+
graph: BeanGraph, observation: ParsedObservation, expected: ExpectedBean
|
|
283
|
+
) -> list[BeanKey] | None:
|
|
284
|
+
if expected.type_name is None:
|
|
285
|
+
return []
|
|
286
|
+
if expected.context_id is None:
|
|
287
|
+
if graph.coverage != "complete":
|
|
288
|
+
return None
|
|
289
|
+
return sorted(graph.beans)
|
|
290
|
+
start = ContextKey(
|
|
291
|
+
observation.target_id, observation.snapshot_id, expected.context_id
|
|
292
|
+
)
|
|
293
|
+
if start not in graph.contexts:
|
|
294
|
+
return [] if graph.coverage == "complete" else None
|
|
295
|
+
visible = visible_beans(graph, start)
|
|
296
|
+
return None if visible is None else sorted(visible)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _start_contexts(
|
|
300
|
+
graph: BeanGraph, observation: ParsedObservation, context_id: str | None
|
|
301
|
+
) -> list[ContextKey] | None:
|
|
302
|
+
if context_id is None:
|
|
303
|
+
if graph.coverage != "complete":
|
|
304
|
+
return None
|
|
305
|
+
return sorted(graph.contexts)
|
|
306
|
+
key = ContextKey(observation.target_id, observation.snapshot_id, context_id)
|
|
307
|
+
if key not in graph.contexts:
|
|
308
|
+
return [] if graph.coverage == "complete" else None
|
|
309
|
+
return [key]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _absent_bean(
|
|
313
|
+
graph: BeanGraph,
|
|
314
|
+
observation: ParsedObservation,
|
|
315
|
+
expected: ExpectedBean,
|
|
316
|
+
gaps: list[str],
|
|
317
|
+
) -> dict[str, Any] | None:
|
|
318
|
+
if not _absence_is_observed(graph, observation, expected):
|
|
319
|
+
gaps.append("BEAN_INDEX_INCOMPLETE")
|
|
320
|
+
return None
|
|
321
|
+
identity = (
|
|
322
|
+
f"{expected.context_id}:{expected.name}"
|
|
323
|
+
if expected.context_id and expected.name
|
|
324
|
+
else f"unscoped:{expected.name or expected.type_name or 'bean'}"
|
|
325
|
+
)
|
|
326
|
+
row = _record(
|
|
327
|
+
"beans",
|
|
328
|
+
identity,
|
|
329
|
+
expected.context_id,
|
|
330
|
+
expected.type_name or "present",
|
|
331
|
+
"absent",
|
|
332
|
+
"observed",
|
|
333
|
+
[],
|
|
334
|
+
)
|
|
335
|
+
if row is None:
|
|
336
|
+
gaps.append("IDENTITY_NOT_SAFE")
|
|
337
|
+
return row
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _type_only(
|
|
341
|
+
graph: BeanGraph,
|
|
342
|
+
observation: ParsedObservation,
|
|
343
|
+
expected: ExpectedBean,
|
|
344
|
+
keys: list[BeanKey],
|
|
345
|
+
catalog: SourceCatalog,
|
|
346
|
+
budget: AnalysisBudget,
|
|
347
|
+
gaps: list[str],
|
|
348
|
+
max_depth: int,
|
|
349
|
+
) -> tuple[dict[str, Any] | None, set[BeanKey], bool]:
|
|
350
|
+
if expected.type_name is None:
|
|
351
|
+
gaps.append("BEAN_INDEX_INCOMPLETE")
|
|
352
|
+
return None, set(), True
|
|
353
|
+
matched: set[BeanKey] = set()
|
|
354
|
+
mismatches: list[BeanKey] = []
|
|
355
|
+
unknown = False
|
|
356
|
+
for key in keys:
|
|
357
|
+
relation = _type_relation(graph.beans[key], expected.type_name, catalog, budget)
|
|
358
|
+
if relation == "match" and _aliases_match(graph.beans[key], expected.aliases):
|
|
359
|
+
matched.add(key)
|
|
360
|
+
elif relation == "match":
|
|
361
|
+
mismatches.append(key)
|
|
362
|
+
elif relation in ("unknown", "unreported"):
|
|
363
|
+
unknown = True
|
|
364
|
+
if matched:
|
|
365
|
+
return None, matched, False
|
|
366
|
+
if len(mismatches) == 1 and not unknown:
|
|
367
|
+
row, cut = _mismatch_record(
|
|
368
|
+
graph, mismatches[0], expected, catalog, budget, gaps, max_depth
|
|
369
|
+
)
|
|
370
|
+
return row, {mismatches[0]}, cut or row is None
|
|
371
|
+
if mismatches or unknown:
|
|
372
|
+
gaps.append("BEAN_SCOPE_AMBIGUOUS" if mismatches else "SOURCE_TYPE_UNOBSERVED")
|
|
373
|
+
return None, set(), True
|
|
374
|
+
if not _absence_is_observed(graph, observation, expected):
|
|
375
|
+
gaps.append("BEAN_INDEX_INCOMPLETE")
|
|
376
|
+
return None, set(), True
|
|
377
|
+
row = _absent_bean(graph, observation, expected, gaps)
|
|
378
|
+
return row, set(), row is None
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _mismatch_record(
|
|
382
|
+
graph: BeanGraph,
|
|
383
|
+
key: BeanKey,
|
|
384
|
+
expected: ExpectedBean,
|
|
385
|
+
catalog: SourceCatalog,
|
|
386
|
+
budget: AnalysisBudget,
|
|
387
|
+
gaps: list[str],
|
|
388
|
+
max_depth: int,
|
|
389
|
+
) -> tuple[dict[str, Any] | None, bool]:
|
|
390
|
+
bean = graph.beans[key]
|
|
391
|
+
affected, cut = _affected(graph, key, max_depth, budget)
|
|
392
|
+
if cut:
|
|
393
|
+
gaps.append("AFFECTED_DEPENDENCY_LIMIT")
|
|
394
|
+
if (
|
|
395
|
+
expected.type_name
|
|
396
|
+
and _type_relation(bean, expected.type_name, catalog, budget) == "mismatch"
|
|
397
|
+
):
|
|
398
|
+
expected_text = expected.type_name
|
|
399
|
+
actual_text = bean.reported_type.value or "unknown"
|
|
400
|
+
else:
|
|
401
|
+
expected_text = next(
|
|
402
|
+
(alias for alias in expected.aliases if alias not in bean.aliases), "alias"
|
|
403
|
+
)
|
|
404
|
+
actual_text = "alias-absent"
|
|
405
|
+
row = _record(
|
|
406
|
+
"beans",
|
|
407
|
+
f"{key.context.context_id}:{key.canonical_name}",
|
|
408
|
+
key.context.context_id,
|
|
409
|
+
expected_text,
|
|
410
|
+
actual_text,
|
|
411
|
+
"observed",
|
|
412
|
+
affected,
|
|
413
|
+
)
|
|
414
|
+
if row is None:
|
|
415
|
+
gaps.append("IDENTITY_NOT_SAFE")
|
|
416
|
+
return None, True
|
|
417
|
+
return row, cut
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _absence_is_observed(
|
|
421
|
+
graph: BeanGraph, observation: ParsedObservation, expected: ExpectedBean
|
|
422
|
+
) -> bool:
|
|
423
|
+
if expected.context_id is None:
|
|
424
|
+
return graph.coverage == "complete"
|
|
425
|
+
key = ContextKey(
|
|
426
|
+
observation.target_id, observation.snapshot_id, expected.context_id
|
|
427
|
+
)
|
|
428
|
+
if key not in graph.contexts:
|
|
429
|
+
return graph.coverage == "complete"
|
|
430
|
+
if expected.name is None:
|
|
431
|
+
return True
|
|
432
|
+
resolution = resolve_bean_reference(graph, key, expected.name)
|
|
433
|
+
return resolution.reason_code == "NOT_FOUND_IN_COMPLETE_VISIBLE_HIERARCHY"
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _route_records(
|
|
437
|
+
query: DriftQuery, body: Any, gaps: list[str]
|
|
438
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], bool]:
|
|
439
|
+
observed, parsed_gaps = normalize_mappings(
|
|
440
|
+
body,
|
|
441
|
+
path_pattern=None,
|
|
442
|
+
http_method=None,
|
|
443
|
+
controller_pattern=None,
|
|
444
|
+
context_id=None,
|
|
445
|
+
)
|
|
446
|
+
gaps.extend(parsed_gaps)
|
|
447
|
+
incomplete = bool(parsed_gaps)
|
|
448
|
+
routes = _explode_routes(observed)
|
|
449
|
+
expectations = query.expected_routes or ()
|
|
450
|
+
missing: list[dict[str, Any]] = []
|
|
451
|
+
covered: set[tuple[str, str, str]] = set()
|
|
452
|
+
for expected in expectations:
|
|
453
|
+
match = next(
|
|
454
|
+
(route for route in routes if _route_matches(route, expected)), None
|
|
455
|
+
)
|
|
456
|
+
if match is not None:
|
|
457
|
+
covered.add(match["key"])
|
|
458
|
+
continue
|
|
459
|
+
if incomplete:
|
|
460
|
+
continue
|
|
461
|
+
identity = (
|
|
462
|
+
f"{expected.context_id}:{expected.http_method}:{expected.path}"
|
|
463
|
+
if expected.context_id
|
|
464
|
+
else f"unscoped:{expected.http_method}:{expected.path}"
|
|
465
|
+
)
|
|
466
|
+
row = _record(
|
|
467
|
+
"routes",
|
|
468
|
+
identity,
|
|
469
|
+
expected.context_id,
|
|
470
|
+
expected.controller_class or "present",
|
|
471
|
+
"absent",
|
|
472
|
+
"observed",
|
|
473
|
+
[],
|
|
474
|
+
)
|
|
475
|
+
if row is None:
|
|
476
|
+
gaps.append("IDENTITY_NOT_SAFE")
|
|
477
|
+
incomplete = True
|
|
478
|
+
continue
|
|
479
|
+
missing.append(row)
|
|
480
|
+
unexpected: list[dict[str, Any]] = []
|
|
481
|
+
closed = (
|
|
482
|
+
query.expected_scope_complete
|
|
483
|
+
and not incomplete
|
|
484
|
+
and all(item.context_id is not None for item in expectations)
|
|
485
|
+
)
|
|
486
|
+
if closed:
|
|
487
|
+
seen: set[tuple[str, str, str]] = set()
|
|
488
|
+
for route in routes:
|
|
489
|
+
if route["key"] in covered or route["key"] in seen:
|
|
490
|
+
continue
|
|
491
|
+
seen.add(route["key"])
|
|
492
|
+
row = _record(
|
|
493
|
+
"routes",
|
|
494
|
+
f"{route['contextId']}:{route['method']}:{route['path']}",
|
|
495
|
+
route["contextId"],
|
|
496
|
+
"absent",
|
|
497
|
+
"present",
|
|
498
|
+
"observed",
|
|
499
|
+
[],
|
|
500
|
+
)
|
|
501
|
+
if row is None:
|
|
502
|
+
gaps.append("IDENTITY_NOT_SAFE")
|
|
503
|
+
incomplete = True
|
|
504
|
+
continue
|
|
505
|
+
unexpected.append(row)
|
|
506
|
+
return missing, unexpected, incomplete
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _explode_routes(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
510
|
+
routes: list[dict[str, Any]] = []
|
|
511
|
+
for row in rows:
|
|
512
|
+
methods = row["httpMethods"] or ["ANY"]
|
|
513
|
+
for path in row["paths"]:
|
|
514
|
+
if not isinstance(path, str):
|
|
515
|
+
continue
|
|
516
|
+
for method in methods:
|
|
517
|
+
routes.append(
|
|
518
|
+
{
|
|
519
|
+
"key": (row["contextId"], method, path),
|
|
520
|
+
"contextId": row["contextId"],
|
|
521
|
+
"method": method,
|
|
522
|
+
"path": path,
|
|
523
|
+
"controller": row["controllerClass"],
|
|
524
|
+
}
|
|
525
|
+
)
|
|
526
|
+
return routes
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _route_matches(route: dict[str, Any], expected: ExpectedRoute) -> bool:
|
|
530
|
+
if expected.context_id is not None and route["contextId"] != expected.context_id:
|
|
531
|
+
return False
|
|
532
|
+
if route["path"] != expected.path:
|
|
533
|
+
return False
|
|
534
|
+
if route["method"] not in ("ANY", expected.http_method):
|
|
535
|
+
return False
|
|
536
|
+
return not (
|
|
537
|
+
expected.controller_class and route["controller"] != expected.controller_class
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _migration_record(
|
|
542
|
+
query: DriftQuery, body: Any | None, gaps: list[str]
|
|
543
|
+
) -> tuple[list[dict[str, Any]], bool]:
|
|
544
|
+
expected = query.expected_migration
|
|
545
|
+
if expected is None:
|
|
546
|
+
return [], False
|
|
547
|
+
if expected.engine == "liquibase":
|
|
548
|
+
gaps.append("MIGRATION_VERSION_UNSUPPORTED")
|
|
549
|
+
if body is None:
|
|
550
|
+
gaps.append("MIGRATION_ENDPOINT_UNOBSERVED")
|
|
551
|
+
return [], True
|
|
552
|
+
if body is None:
|
|
553
|
+
gaps.append("MIGRATION_ENDPOINT_UNOBSERVED")
|
|
554
|
+
return [], True
|
|
555
|
+
rows, parsed_gaps, _visibility = normalize_migrations(
|
|
556
|
+
body, None, MigrationQuery(engine="flyway")
|
|
557
|
+
)
|
|
558
|
+
gaps.extend(parsed_gaps)
|
|
559
|
+
selected = [
|
|
560
|
+
row
|
|
561
|
+
for row in rows
|
|
562
|
+
if row["engine"] == "flyway"
|
|
563
|
+
and (expected.context_id is None or row["contextId"] == expected.context_id)
|
|
564
|
+
and (
|
|
565
|
+
expected.migration_bean is None
|
|
566
|
+
or row["migrationBean"] == expected.migration_bean
|
|
567
|
+
)
|
|
568
|
+
]
|
|
569
|
+
groups: dict[tuple[str | None, str], list[dict[str, Any]]] = {}
|
|
570
|
+
for row in selected:
|
|
571
|
+
groups.setdefault((row["contextId"], row["migrationBean"]), []).append(row)
|
|
572
|
+
obscured = any(gap in _FLYWAY_GAPS for gap in parsed_gaps)
|
|
573
|
+
if len(groups) > 1:
|
|
574
|
+
gaps.append("MIGRATION_SCOPE_AMBIGUOUS")
|
|
575
|
+
return [], True
|
|
576
|
+
if not groups:
|
|
577
|
+
if obscured:
|
|
578
|
+
gaps.append("MIGRATION_VERSION_UNOBSERVED")
|
|
579
|
+
return [], True
|
|
580
|
+
identity = (
|
|
581
|
+
f"{expected.context_id or 'unscoped'}:{expected.migration_bean or 'flyway'}"
|
|
582
|
+
)
|
|
583
|
+
absent = _record(
|
|
584
|
+
"migrations",
|
|
585
|
+
identity,
|
|
586
|
+
expected.context_id,
|
|
587
|
+
expected.version,
|
|
588
|
+
"absent",
|
|
589
|
+
"observed",
|
|
590
|
+
[],
|
|
591
|
+
)
|
|
592
|
+
if absent is None:
|
|
593
|
+
gaps.append("IDENTITY_NOT_SAFE")
|
|
594
|
+
return [], True
|
|
595
|
+
return [absent], False
|
|
596
|
+
context_id, bean_name = next(iter(groups))
|
|
597
|
+
installed, install_state = _installed_version(groups[(context_id, bean_name)])
|
|
598
|
+
if install_state == "unknown" or (install_state == "ok" and installed is None):
|
|
599
|
+
gaps.append("MIGRATION_ORDER_UNKNOWN")
|
|
600
|
+
return [], True
|
|
601
|
+
actual = installed if install_state == "ok" and installed is not None else "absent"
|
|
602
|
+
if actual == expected.version:
|
|
603
|
+
return [], obscured
|
|
604
|
+
diff = _record(
|
|
605
|
+
"migrations",
|
|
606
|
+
f"{context_id or 'unscoped'}:{bean_name}",
|
|
607
|
+
context_id if isinstance(context_id, str) else None,
|
|
608
|
+
expected.version,
|
|
609
|
+
actual,
|
|
610
|
+
"observed",
|
|
611
|
+
[],
|
|
612
|
+
)
|
|
613
|
+
if diff is None:
|
|
614
|
+
gaps.append("IDENTITY_NOT_SAFE")
|
|
615
|
+
return [], True
|
|
616
|
+
return [diff], obscured
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _installed_version(rows: list[dict[str, Any]]) -> tuple[str | None, str]:
|
|
620
|
+
ranked: list[tuple[int, str]] = []
|
|
621
|
+
saw_unranked = False
|
|
622
|
+
for row in rows:
|
|
623
|
+
if row.get("rawState") not in {
|
|
624
|
+
"SUCCESS",
|
|
625
|
+
"OUT_OF_ORDER",
|
|
626
|
+
"MISSING_SUCCESS",
|
|
627
|
+
"FUTURE_SUCCESS",
|
|
628
|
+
} or not isinstance(row.get("version"), str):
|
|
629
|
+
continue
|
|
630
|
+
rank = row.get("orderExecuted")
|
|
631
|
+
if type(rank) is not int:
|
|
632
|
+
saw_unranked = True
|
|
633
|
+
continue
|
|
634
|
+
ranked.append((rank, row["version"]))
|
|
635
|
+
if saw_unranked:
|
|
636
|
+
return None, "unknown"
|
|
637
|
+
if not ranked:
|
|
638
|
+
return None, "absent"
|
|
639
|
+
top = max(rank for rank, _version in ranked)
|
|
640
|
+
winners = [version for rank, version in ranked if rank == top]
|
|
641
|
+
if len(set(winners)) != 1:
|
|
642
|
+
return None, "unknown"
|
|
643
|
+
return winners[0], "ok"
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _profiles(
|
|
647
|
+
expected: tuple[str, ...], body: Any | None, gaps: list[str]
|
|
648
|
+
) -> tuple[dict[str, Any], bool, bool]:
|
|
649
|
+
safe_expected, expected_unsafe = _profile_tokens(expected)
|
|
650
|
+
if body is None:
|
|
651
|
+
gaps.append("PROFILE_UNOBSERVED")
|
|
652
|
+
return (
|
|
653
|
+
{"expected": safe_expected, "actual": None, "state": "unknown"},
|
|
654
|
+
True,
|
|
655
|
+
False,
|
|
656
|
+
)
|
|
657
|
+
if not isinstance(body, dict) or not isinstance(body.get("activeProfiles"), list):
|
|
658
|
+
gaps.append("ENV_PROFILES_UNOBSERVED")
|
|
659
|
+
return (
|
|
660
|
+
{"expected": safe_expected, "actual": None, "state": "unknown"},
|
|
661
|
+
True,
|
|
662
|
+
False,
|
|
663
|
+
)
|
|
664
|
+
actual, actual_unsafe = _profile_tokens(tuple(body["activeProfiles"]))
|
|
665
|
+
override = _environment_override(body)
|
|
666
|
+
if expected_unsafe or actual_unsafe:
|
|
667
|
+
gaps.append("PROFILE_TOKEN_UNSAFE")
|
|
668
|
+
return (
|
|
669
|
+
{"expected": safe_expected, "actual": None, "state": "unknown"},
|
|
670
|
+
True,
|
|
671
|
+
False,
|
|
672
|
+
)
|
|
673
|
+
state = "match" if set(safe_expected) == set(actual) else "mismatch"
|
|
674
|
+
return (
|
|
675
|
+
{"expected": safe_expected, "actual": actual, "state": state},
|
|
676
|
+
False,
|
|
677
|
+
override and state == "mismatch",
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _profile_tokens(values: tuple[Any, ...]) -> tuple[list[str], bool]:
|
|
682
|
+
safe: list[str] = []
|
|
683
|
+
unsafe = False
|
|
684
|
+
for value in values:
|
|
685
|
+
if isinstance(value, str) and _PROFILE.fullmatch(value):
|
|
686
|
+
safe.append(value)
|
|
687
|
+
else:
|
|
688
|
+
unsafe = True
|
|
689
|
+
return sorted(set(safe)), unsafe
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _environment_override(body: dict[str, Any]) -> bool:
|
|
693
|
+
single = body.get("property")
|
|
694
|
+
if (
|
|
695
|
+
isinstance(single, dict)
|
|
696
|
+
and single.get("source") == "systemEnvironment"
|
|
697
|
+
and _visible_scalar(single.get("value"))
|
|
698
|
+
):
|
|
699
|
+
return True
|
|
700
|
+
sources = body.get("propertySources")
|
|
701
|
+
if not isinstance(sources, list):
|
|
702
|
+
return False
|
|
703
|
+
for source in sources:
|
|
704
|
+
if not isinstance(source, dict):
|
|
705
|
+
continue
|
|
706
|
+
properties = source.get("properties")
|
|
707
|
+
if (
|
|
708
|
+
not isinstance(properties, dict)
|
|
709
|
+
or "spring.profiles.active" not in properties
|
|
710
|
+
):
|
|
711
|
+
continue
|
|
712
|
+
raw_name = source.get("name")
|
|
713
|
+
name = raw_name if isinstance(raw_name, str) else ""
|
|
714
|
+
winning = "systemenvironment" in name.casefold().replace("_", "")
|
|
715
|
+
spec = properties["spring.profiles.active"]
|
|
716
|
+
raw = spec.get("value") if isinstance(spec, dict) else spec
|
|
717
|
+
return winning and _visible_scalar(raw)
|
|
718
|
+
return False
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _visible_scalar(value: Any) -> bool:
|
|
722
|
+
if isinstance(value, str):
|
|
723
|
+
return (
|
|
724
|
+
bool(value)
|
|
725
|
+
and not any(char.isspace() for char in value)
|
|
726
|
+
and set(value) != {"*"}
|
|
727
|
+
)
|
|
728
|
+
return type(value) in (int, bool)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def compare_drift(
|
|
732
|
+
query: DriftQuery,
|
|
733
|
+
*,
|
|
734
|
+
beans: ParsedObservation | None,
|
|
735
|
+
mappings_body: Any | None,
|
|
736
|
+
flyway_body: Any | None,
|
|
737
|
+
migration_observed: bool,
|
|
738
|
+
env_body: Any | None,
|
|
739
|
+
catalog: SourceCatalog,
|
|
740
|
+
) -> dict[str, Any]:
|
|
741
|
+
gaps: list[str] = []
|
|
742
|
+
missing: list[dict[str, Any]] = []
|
|
743
|
+
unexpected: list[dict[str, Any]] = []
|
|
744
|
+
incomplete = False
|
|
745
|
+
if query.expected_beans is not None:
|
|
746
|
+
if beans is None:
|
|
747
|
+
gaps.append("BEANS_UNOBSERVED")
|
|
748
|
+
incomplete = True
|
|
749
|
+
else:
|
|
750
|
+
bean_missing, bean_unexpected, bean_incomplete = _bean_records(
|
|
751
|
+
query, beans, catalog, gaps
|
|
752
|
+
)
|
|
753
|
+
missing.extend(bean_missing)
|
|
754
|
+
unexpected.extend(bean_unexpected)
|
|
755
|
+
incomplete = incomplete or bean_incomplete
|
|
756
|
+
if query.expected_routes is not None:
|
|
757
|
+
if mappings_body is None:
|
|
758
|
+
gaps.append("MAPPINGS_UNOBSERVED")
|
|
759
|
+
incomplete = True
|
|
760
|
+
else:
|
|
761
|
+
route_missing, route_unexpected, route_incomplete = _route_records(
|
|
762
|
+
query, mappings_body, gaps
|
|
763
|
+
)
|
|
764
|
+
missing.extend(route_missing)
|
|
765
|
+
unexpected.extend(route_unexpected)
|
|
766
|
+
incomplete = incomplete or route_incomplete
|
|
767
|
+
override = False
|
|
768
|
+
profile = None
|
|
769
|
+
if query.expected_migration is not None:
|
|
770
|
+
body = (
|
|
771
|
+
flyway_body
|
|
772
|
+
if query.expected_migration.engine == "flyway"
|
|
773
|
+
else ({} if migration_observed else None)
|
|
774
|
+
)
|
|
775
|
+
migration_rows, migration_incomplete = _migration_record(query, body, gaps)
|
|
776
|
+
missing.extend(migration_rows)
|
|
777
|
+
incomplete = incomplete or migration_incomplete
|
|
778
|
+
if query.expected_profiles is not None:
|
|
779
|
+
profile, profile_incomplete, override = _profiles(
|
|
780
|
+
query.expected_profiles, env_body, gaps
|
|
781
|
+
)
|
|
782
|
+
incomplete = incomplete or profile_incomplete
|
|
783
|
+
return {
|
|
784
|
+
"missing": _sort(missing),
|
|
785
|
+
"unexpected": _sort(unexpected),
|
|
786
|
+
"profile": profile,
|
|
787
|
+
"gaps": sorted(set(gaps)),
|
|
788
|
+
"incomplete": incomplete or bool(gaps),
|
|
789
|
+
"environment_override": override,
|
|
790
|
+
}
|