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 @@
|
|
|
1
|
+
"""Tool orchestration independent of a specific MCP host."""
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Read-only startup timeline handler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from ..adapters.startup import normalize_startup
|
|
10
|
+
from ..contracts.definitions import validate_arguments
|
|
11
|
+
from ..models.common import NormalizationError
|
|
12
|
+
from ..models.queries import StartupQuery
|
|
13
|
+
from ..transport.response_gate import EndpointReadError
|
|
14
|
+
from ..services import Services
|
|
15
|
+
from ..diagnostics import failed, partial
|
|
16
|
+
from .support import tool_handler, finalize, read_checked, resume, shell, stamp
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@tool_handler
|
|
20
|
+
async def analyze_startup(
|
|
21
|
+
arguments: Mapping[str, Any], services: Services
|
|
22
|
+
) -> dict[str, Any]:
|
|
23
|
+
request_target = "local"
|
|
24
|
+
try:
|
|
25
|
+
query = validate_arguments(arguments, "springboot_analyze_startup")
|
|
26
|
+
request_target = query.target_id
|
|
27
|
+
assert isinstance(query, StartupQuery)
|
|
28
|
+
if query.cursor:
|
|
29
|
+
return resume(services, query)
|
|
30
|
+
snapshot = uuid4().hex
|
|
31
|
+
observation = await read_checked(services, query.target_id, "startup", snapshot)
|
|
32
|
+
version, total, basis, complete, items, gaps = normalize_startup(
|
|
33
|
+
observation.body,
|
|
34
|
+
step_name_pattern=query.step_name_pattern,
|
|
35
|
+
bean_name_pattern=query.bean_name_pattern,
|
|
36
|
+
min_duration_ms=query.min_duration_ms,
|
|
37
|
+
max_depth=query.max_depth,
|
|
38
|
+
)
|
|
39
|
+
result = shell(query.target_id, "registered target startup", "startup-1")
|
|
40
|
+
stamp(result, observation, "startup", snapshot)
|
|
41
|
+
for index, item in enumerate(items, start=1):
|
|
42
|
+
item["id"] = f"startup_{index:06d}"
|
|
43
|
+
item["endpointId"] = "startup"
|
|
44
|
+
item["observationId"] = observation.observation_id
|
|
45
|
+
result["data"] = {
|
|
46
|
+
"springBootVersion": version,
|
|
47
|
+
"totalDurationMs": total,
|
|
48
|
+
"durationBasis": basis,
|
|
49
|
+
"timelineComplete": complete,
|
|
50
|
+
"items": items,
|
|
51
|
+
}
|
|
52
|
+
limited = {
|
|
53
|
+
"STARTUP_NODE_LIMIT",
|
|
54
|
+
"STARTUP_TAG_LIMIT",
|
|
55
|
+
"STARTUP_DEPTH_LIMIT",
|
|
56
|
+
} & set(gaps)
|
|
57
|
+
result["totalMatched"] = None if limited else len(items)
|
|
58
|
+
if limited:
|
|
59
|
+
result["truncated"] = True
|
|
60
|
+
result["truncationReasons"] = sorted(
|
|
61
|
+
set(result["truncationReasons"] + list(limited))
|
|
62
|
+
)[:8]
|
|
63
|
+
if not complete:
|
|
64
|
+
gaps = [*gaps, "STARTUP_TIMELINE_INCOMPLETE"]
|
|
65
|
+
partial(result, gaps)
|
|
66
|
+
return finalize(result, services, query)
|
|
67
|
+
except NormalizationError as exc:
|
|
68
|
+
return failed(
|
|
69
|
+
exc.code,
|
|
70
|
+
request_target,
|
|
71
|
+
endpoint=exc.endpoint_id if isinstance(exc, EndpointReadError) else None,
|
|
72
|
+
http_status=exc.http_status if isinstance(exc, EndpointReadError) else None,
|
|
73
|
+
)
|
|
74
|
+
except TimeoutError:
|
|
75
|
+
return failed("TARGET_TIMEOUT", request_target)
|
|
76
|
+
except (ValueError, TypeError, KeyError, RecursionError, UnicodeError):
|
|
77
|
+
return failed("MALFORMED_EVIDENCE", request_target)
|
|
78
|
+
except Exception:
|
|
79
|
+
return failed("INTERNAL_ERROR", request_target)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Read-only Flyway and Liquibase handler. It never executes migrations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from ..adapters.migrations import normalize_migrations
|
|
10
|
+
from ..contracts.definitions import validate_arguments
|
|
11
|
+
from ..models.common import NormalizationError
|
|
12
|
+
from ..models.queries import MigrationQuery
|
|
13
|
+
from ..transport.response_gate import EndpointReadError
|
|
14
|
+
from ..services import Services
|
|
15
|
+
from ..diagnostics import error, failed, partial, safe_code
|
|
16
|
+
from .support import tool_handler, finalize, read_checked, resume, shell, stamp
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@tool_handler
|
|
20
|
+
async def check_migrations(
|
|
21
|
+
arguments: Mapping[str, Any], services: Services
|
|
22
|
+
) -> dict[str, Any]:
|
|
23
|
+
request_target = "local"
|
|
24
|
+
try:
|
|
25
|
+
query = validate_arguments(arguments, "springboot_check_migrations")
|
|
26
|
+
request_target = query.target_id
|
|
27
|
+
assert isinstance(query, MigrationQuery)
|
|
28
|
+
if query.cursor:
|
|
29
|
+
return resume(services, query)
|
|
30
|
+
snapshot = uuid4().hex
|
|
31
|
+
if (
|
|
32
|
+
query.target_id not in services.targets
|
|
33
|
+
or services.catalog.target_id != query.target_id
|
|
34
|
+
):
|
|
35
|
+
raise NormalizationError("TARGET_NOT_REGISTERED")
|
|
36
|
+
engines = ("flyway", "liquibase") if query.engine == "auto" else (query.engine,)
|
|
37
|
+
bodies: dict[str, Any] = {}
|
|
38
|
+
observation_ids: dict[str, str] = {}
|
|
39
|
+
result = shell(query.target_id, "registered target migrations", "migrations-1")
|
|
40
|
+
gaps: list[str] = []
|
|
41
|
+
for endpoint in engines:
|
|
42
|
+
try:
|
|
43
|
+
observation = await read_checked(
|
|
44
|
+
services, query.target_id, endpoint, snapshot
|
|
45
|
+
)
|
|
46
|
+
except NormalizationError as exc:
|
|
47
|
+
gaps.append(safe_code(exc.code))
|
|
48
|
+
result["errors"].append(
|
|
49
|
+
error(
|
|
50
|
+
exc.code,
|
|
51
|
+
endpoint,
|
|
52
|
+
exc.http_status if isinstance(exc, EndpointReadError) else None,
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
continue
|
|
56
|
+
bodies[endpoint] = observation.body
|
|
57
|
+
observation_ids[endpoint] = observation.observation_id
|
|
58
|
+
stamp(result, observation, endpoint, snapshot)
|
|
59
|
+
if not bodies and result["errors"]:
|
|
60
|
+
return failed(
|
|
61
|
+
result["errors"][0]["code"],
|
|
62
|
+
query.target_id,
|
|
63
|
+
endpoint=result["errors"][0]["endpointId"],
|
|
64
|
+
http_status=result["errors"][0]["httpStatus"],
|
|
65
|
+
)
|
|
66
|
+
items, parsed_gaps, visibility = normalize_migrations(
|
|
67
|
+
bodies.get("flyway"), bodies.get("liquibase"), query
|
|
68
|
+
)
|
|
69
|
+
gaps.extend(parsed_gaps)
|
|
70
|
+
invalid = [
|
|
71
|
+
engine
|
|
72
|
+
for engine in bodies
|
|
73
|
+
if engine.upper() + "_EVIDENCE_INVALID" in parsed_gaps
|
|
74
|
+
]
|
|
75
|
+
if invalid and len(invalid) == len(bodies):
|
|
76
|
+
return failed("MALFORMED_EVIDENCE", query.target_id, endpoint=invalid[0])
|
|
77
|
+
for engine in invalid:
|
|
78
|
+
result["errors"].append(error("MALFORMED_EVIDENCE", engine))
|
|
79
|
+
for item in items:
|
|
80
|
+
item["observationId"] = observation_ids.get(
|
|
81
|
+
item["endpointId"], "observation_baseline"
|
|
82
|
+
)
|
|
83
|
+
result["data"] = {"pendingVisibility": visibility, "items": items}
|
|
84
|
+
result["totalMatched"] = None if result["errors"] or gaps else len(items)
|
|
85
|
+
partial(result, gaps)
|
|
86
|
+
return finalize(result, services, query)
|
|
87
|
+
except NormalizationError as exc:
|
|
88
|
+
return failed(
|
|
89
|
+
exc.code,
|
|
90
|
+
request_target,
|
|
91
|
+
endpoint=exc.endpoint_id if isinstance(exc, EndpointReadError) else None,
|
|
92
|
+
http_status=exc.http_status if isinstance(exc, EndpointReadError) else None,
|
|
93
|
+
)
|
|
94
|
+
except TimeoutError:
|
|
95
|
+
return failed("TARGET_TIMEOUT", request_target)
|
|
96
|
+
except (ValueError, TypeError, KeyError, RecursionError, UnicodeError):
|
|
97
|
+
return failed("MALFORMED_EVIDENCE", request_target)
|
|
98
|
+
except Exception:
|
|
99
|
+
return failed("INTERNAL_ERROR", request_target)
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Compare caller-supplied expectations with a read-only runtime snapshot."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from ..adapters.drift import compare_drift
|
|
11
|
+
from ..contracts.definitions import validate_arguments
|
|
12
|
+
from ..models.common import NormalizationError
|
|
13
|
+
from ..models.queries import DriftQuery
|
|
14
|
+
from ..models.timestamps import parse_rfc3339
|
|
15
|
+
from ..transport.response_gate import EndpointReadError
|
|
16
|
+
from ..services import Services
|
|
17
|
+
from ..diagnostics import error, failed, partial, safe_code
|
|
18
|
+
from .support import tool_handler, finalize, read_checked, resume, shell, stamp
|
|
19
|
+
|
|
20
|
+
_VERSION = re.compile(r"[A-Za-z0-9_.-]{1,64}\Z")
|
|
21
|
+
_STARTUP_UNAVAILABLE = frozenset(
|
|
22
|
+
{"CAPABILITY_DISABLED", "ENDPOINT_NOT_EXPOSED", "STARTUP_DATA_UNAVAILABLE"}
|
|
23
|
+
)
|
|
24
|
+
_STARTUP_EVIDENCE_GAP = "STARTUP_EVIDENCE_UNAVAILABLE"
|
|
25
|
+
_DIFF_FIELDS = (
|
|
26
|
+
"id",
|
|
27
|
+
"dimension",
|
|
28
|
+
"identity",
|
|
29
|
+
"contextId",
|
|
30
|
+
"expected",
|
|
31
|
+
"actual",
|
|
32
|
+
"evidenceLevel",
|
|
33
|
+
"affectedDependencyIds",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _profile_evidence(body: Any, services: Services) -> Any:
|
|
38
|
+
"""Profiles permission does not grant unregistered property evidence."""
|
|
39
|
+
if not isinstance(body, dict) or services.config_policy is None:
|
|
40
|
+
return None
|
|
41
|
+
sources = []
|
|
42
|
+
for source in (
|
|
43
|
+
body.get("propertySources", [])
|
|
44
|
+
if isinstance(body.get("propertySources"), list)
|
|
45
|
+
else []
|
|
46
|
+
):
|
|
47
|
+
if not isinstance(source, dict) or not isinstance(
|
|
48
|
+
source.get("properties"), dict
|
|
49
|
+
):
|
|
50
|
+
continue
|
|
51
|
+
sources.append(
|
|
52
|
+
{
|
|
53
|
+
"name": source.get("name"),
|
|
54
|
+
"properties": {
|
|
55
|
+
key: value
|
|
56
|
+
for key, value in source["properties"].items()
|
|
57
|
+
if services.config_policy.allows(key)
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
return {"activeProfiles": body.get("activeProfiles"), "propertySources": sources}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _project(page: dict[str, Any]) -> dict[str, Any]:
|
|
65
|
+
data = page.get("data")
|
|
66
|
+
if not isinstance(data, dict) or "items" not in data:
|
|
67
|
+
return page
|
|
68
|
+
missing: list[dict[str, Any]] = []
|
|
69
|
+
unexpected: list[dict[str, Any]] = []
|
|
70
|
+
for item in data.pop("items") or []:
|
|
71
|
+
if not isinstance(item, dict):
|
|
72
|
+
continue
|
|
73
|
+
record = {field: item.get(field) for field in _DIFF_FIELDS}
|
|
74
|
+
if not isinstance(record["affectedDependencyIds"], list):
|
|
75
|
+
record["affectedDependencyIds"] = []
|
|
76
|
+
if str(record.get("id") or "").startswith("u"):
|
|
77
|
+
unexpected.append(record)
|
|
78
|
+
else:
|
|
79
|
+
missing.append(record)
|
|
80
|
+
data["missingInRuntime"] = missing
|
|
81
|
+
data["unexpectedInRuntime"] = unexpected
|
|
82
|
+
data.setdefault("profileMismatch", None)
|
|
83
|
+
data.setdefault("rootCauseInferences", [])
|
|
84
|
+
return page
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _startup_start(body: Any) -> str | None:
|
|
88
|
+
if not isinstance(body, dict) or not isinstance(body.get("timeline"), dict):
|
|
89
|
+
return None
|
|
90
|
+
start = body["timeline"].get("startTime")
|
|
91
|
+
return (
|
|
92
|
+
start if isinstance(start, str) and parse_rfc3339(start) is not None else None
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _later(modified: str, started: str) -> bool:
|
|
97
|
+
left, right = parse_rfc3339(modified), parse_rfc3339(started)
|
|
98
|
+
return left is not None and right is not None and left > right
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _inferences(
|
|
102
|
+
missing: list[dict[str, Any]],
|
|
103
|
+
unexpected: list[dict[str, Any]],
|
|
104
|
+
profile: dict[str, Any] | None,
|
|
105
|
+
*,
|
|
106
|
+
environment_override: bool,
|
|
107
|
+
not_restarted: bool,
|
|
108
|
+
) -> list[dict[str, Any]]:
|
|
109
|
+
evidence = [row["id"] for row in (*missing, *unexpected)][:8]
|
|
110
|
+
drifted = bool(
|
|
111
|
+
missing or unexpected or (profile and profile.get("state") == "mismatch")
|
|
112
|
+
)
|
|
113
|
+
rows: list[dict[str, Any]] = []
|
|
114
|
+
if profile and profile.get("state") == "mismatch":
|
|
115
|
+
rows.append(
|
|
116
|
+
{"code": "PROFILE_MISMATCH", "confidence": "supported", "evidenceIds": []}
|
|
117
|
+
)
|
|
118
|
+
if environment_override:
|
|
119
|
+
rows.append(
|
|
120
|
+
{
|
|
121
|
+
"code": "ENVIRONMENT_OVERRIDE",
|
|
122
|
+
"confidence": "supported",
|
|
123
|
+
"evidenceIds": [],
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
if not_restarted and drifted:
|
|
127
|
+
rows.append(
|
|
128
|
+
{
|
|
129
|
+
"code": "POSSIBLE_NOT_RESTARTED",
|
|
130
|
+
"confidence": "candidate",
|
|
131
|
+
"evidenceIds": list(evidence),
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
if missing or unexpected:
|
|
135
|
+
rows.append(
|
|
136
|
+
{
|
|
137
|
+
"code": "INSUFFICIENT_EVIDENCE",
|
|
138
|
+
"confidence": "unknown",
|
|
139
|
+
"evidenceIds": list(evidence),
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
return rows[:4]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _assign(rows: list[dict[str, Any]], prefix: str) -> None:
|
|
146
|
+
for index, row in enumerate(rows, start=1):
|
|
147
|
+
row["id"] = f"{prefix}{index:06d}"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def _optional_startup(
|
|
151
|
+
services: Services, query: DriftQuery, snapshot: str, result: dict[str, Any]
|
|
152
|
+
) -> tuple[Any | None, list[dict[str, Any]], list[str]]:
|
|
153
|
+
if query.source_modified_at is None:
|
|
154
|
+
return None, [], []
|
|
155
|
+
try:
|
|
156
|
+
observation = await read_checked(services, query.target_id, "startup", snapshot)
|
|
157
|
+
except NormalizationError as exc:
|
|
158
|
+
if safe_code(exc.code) in _STARTUP_UNAVAILABLE:
|
|
159
|
+
# The caller explicitly supplied sourceModifiedAt, so startup timing
|
|
160
|
+
# is required evidence. Missing it is a gap, never a silent success.
|
|
161
|
+
return None, [], [_STARTUP_EVIDENCE_GAP]
|
|
162
|
+
return (
|
|
163
|
+
None,
|
|
164
|
+
[
|
|
165
|
+
error(
|
|
166
|
+
exc.code,
|
|
167
|
+
"startup",
|
|
168
|
+
exc.http_status if isinstance(exc, EndpointReadError) else None,
|
|
169
|
+
)
|
|
170
|
+
],
|
|
171
|
+
[],
|
|
172
|
+
)
|
|
173
|
+
stamp(result, observation, "startup", snapshot)
|
|
174
|
+
version = (
|
|
175
|
+
observation.body.get("springBootVersion")
|
|
176
|
+
if isinstance(observation.body, dict)
|
|
177
|
+
else None
|
|
178
|
+
)
|
|
179
|
+
if isinstance(version, str) and _VERSION.fullmatch(version):
|
|
180
|
+
result["source"]["bootVersion"] = version
|
|
181
|
+
return observation.body, [], []
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@tool_handler
|
|
185
|
+
async def detect_drift(
|
|
186
|
+
arguments: Mapping[str, Any], services: Services
|
|
187
|
+
) -> dict[str, Any]:
|
|
188
|
+
request_target = "local"
|
|
189
|
+
try:
|
|
190
|
+
query = validate_arguments(arguments, "springboot_detect_drift")
|
|
191
|
+
request_target = query.target_id
|
|
192
|
+
assert isinstance(query, DriftQuery)
|
|
193
|
+
# A caller revision is not an observed build id. Do not infer
|
|
194
|
+
# POSSIBLE_WRONG_INSTANCE unless a future authorized info read proves it.
|
|
195
|
+
if query.source_revision is not None and not isinstance(
|
|
196
|
+
query.source_revision, str
|
|
197
|
+
):
|
|
198
|
+
raise NormalizationError("INVALID_ARGUMENT")
|
|
199
|
+
if query.cursor:
|
|
200
|
+
return _project(resume(services, query))
|
|
201
|
+
snapshot = uuid4().hex
|
|
202
|
+
if (
|
|
203
|
+
query.target_id not in services.targets
|
|
204
|
+
or services.catalog.target_id != query.target_id
|
|
205
|
+
):
|
|
206
|
+
raise NormalizationError("TARGET_NOT_REGISTERED")
|
|
207
|
+
result = shell(query.target_id, "registered-target-drift", "drift-1")
|
|
208
|
+
plan: list[str] = []
|
|
209
|
+
if query.expected_beans is not None:
|
|
210
|
+
plan.append("beans")
|
|
211
|
+
if query.expected_routes is not None:
|
|
212
|
+
plan.append("mappings")
|
|
213
|
+
if query.expected_migration is not None:
|
|
214
|
+
plan.append(query.expected_migration.engine)
|
|
215
|
+
if query.expected_profiles is not None:
|
|
216
|
+
plan.append("env")
|
|
217
|
+
bodies: dict[str, Any] = {}
|
|
218
|
+
failures: list[dict[str, Any]] = []
|
|
219
|
+
for endpoint in plan:
|
|
220
|
+
try:
|
|
221
|
+
if endpoint == "env" and (
|
|
222
|
+
services.config_policy is None
|
|
223
|
+
or not services.config_policy.profiles
|
|
224
|
+
):
|
|
225
|
+
raise EndpointReadError("CAPABILITY_DISABLED", "env")
|
|
226
|
+
observation = await read_checked(
|
|
227
|
+
services, query.target_id, endpoint, snapshot
|
|
228
|
+
)
|
|
229
|
+
except NormalizationError as exc:
|
|
230
|
+
failures.append(
|
|
231
|
+
error(
|
|
232
|
+
exc.code,
|
|
233
|
+
endpoint,
|
|
234
|
+
exc.http_status if isinstance(exc, EndpointReadError) else None,
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
continue
|
|
238
|
+
bodies[endpoint] = observation
|
|
239
|
+
stamp(result, observation, endpoint, snapshot)
|
|
240
|
+
if plan and not bodies and failures:
|
|
241
|
+
first = failures[0]
|
|
242
|
+
return failed(
|
|
243
|
+
first["code"],
|
|
244
|
+
query.target_id,
|
|
245
|
+
endpoint=first["endpointId"],
|
|
246
|
+
http_status=first["httpStatus"],
|
|
247
|
+
)
|
|
248
|
+
startup_body, startup_errors, startup_gaps = await _optional_startup(
|
|
249
|
+
services, query, snapshot, result
|
|
250
|
+
)
|
|
251
|
+
failures.extend(startup_errors)
|
|
252
|
+
compared = compare_drift(
|
|
253
|
+
query,
|
|
254
|
+
beans=bodies.get("beans"),
|
|
255
|
+
mappings_body=None if "mappings" not in bodies else bodies["mappings"].body,
|
|
256
|
+
flyway_body=None if "flyway" not in bodies else bodies["flyway"].body,
|
|
257
|
+
migration_observed="liquibase" in bodies,
|
|
258
|
+
env_body=_profile_evidence(bodies["env"].body, services)
|
|
259
|
+
if "env" in bodies
|
|
260
|
+
else None,
|
|
261
|
+
catalog=services.catalog,
|
|
262
|
+
)
|
|
263
|
+
missing = list(compared["missing"])
|
|
264
|
+
unexpected = list(compared["unexpected"])
|
|
265
|
+
_assign(missing, "m")
|
|
266
|
+
_assign(unexpected, "u")
|
|
267
|
+
started = _startup_start(startup_body)
|
|
268
|
+
not_restarted = bool(
|
|
269
|
+
query.source_modified_at
|
|
270
|
+
and started
|
|
271
|
+
and _later(query.source_modified_at, started)
|
|
272
|
+
)
|
|
273
|
+
if query.source_modified_at and startup_body is not None and started is None:
|
|
274
|
+
compared["gaps"] = [*compared["gaps"], "STARTUP_TIMING_UNOBSERVED"]
|
|
275
|
+
compared["incomplete"] = True
|
|
276
|
+
profile = compared["profile"]
|
|
277
|
+
result["data"] = {
|
|
278
|
+
"items": [*missing, *unexpected],
|
|
279
|
+
"profileMismatch": profile,
|
|
280
|
+
"rootCauseInferences": _inferences(
|
|
281
|
+
missing,
|
|
282
|
+
unexpected,
|
|
283
|
+
profile,
|
|
284
|
+
environment_override=bool(compared["environment_override"]),
|
|
285
|
+
not_restarted=not_restarted,
|
|
286
|
+
),
|
|
287
|
+
}
|
|
288
|
+
result["errors"] = failures[:8]
|
|
289
|
+
gaps = (
|
|
290
|
+
list(compared["gaps"])
|
|
291
|
+
+ [safe_code(item["code"]) for item in failures]
|
|
292
|
+
+ startup_gaps
|
|
293
|
+
)
|
|
294
|
+
if startup_gaps:
|
|
295
|
+
compared["incomplete"] = True
|
|
296
|
+
if compared["incomplete"] and not gaps:
|
|
297
|
+
gaps.append("EVIDENCE_INCOMPLETE")
|
|
298
|
+
result["totalMatched"] = (
|
|
299
|
+
None
|
|
300
|
+
if compared["incomplete"] or failures or startup_gaps
|
|
301
|
+
else len(missing)
|
|
302
|
+
+ len(unexpected)
|
|
303
|
+
+ (
|
|
304
|
+
1
|
|
305
|
+
if isinstance(profile, dict) and profile.get("state") == "mismatch"
|
|
306
|
+
else 0
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
partial(result, gaps)
|
|
310
|
+
return _project(finalize(result, services, query))
|
|
311
|
+
except NormalizationError as exc:
|
|
312
|
+
return failed(
|
|
313
|
+
exc.code,
|
|
314
|
+
request_target,
|
|
315
|
+
endpoint=exc.endpoint_id if isinstance(exc, EndpointReadError) else None,
|
|
316
|
+
http_status=exc.http_status if isinstance(exc, EndpointReadError) else None,
|
|
317
|
+
)
|
|
318
|
+
except TimeoutError:
|
|
319
|
+
return failed("TARGET_TIMEOUT", request_target)
|
|
320
|
+
except (ValueError, TypeError, KeyError, RecursionError, UnicodeError):
|
|
321
|
+
return failed("MALFORMED_EVIDENCE", request_target)
|
|
322
|
+
except Exception:
|
|
323
|
+
return failed("INTERNAL_ERROR", request_target)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Read-only health handler for Boot 2.x details and 3.x/4.x components."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from ..adapters.health import normalize_health
|
|
10
|
+
from ..contracts.definitions import validate_arguments
|
|
11
|
+
from ..models.common import NormalizationError
|
|
12
|
+
from ..models.queries import HealthQuery
|
|
13
|
+
from ..transport.response_gate import EndpointReadError
|
|
14
|
+
from ..services import Services
|
|
15
|
+
from ..diagnostics import failed, partial
|
|
16
|
+
from .support import tool_handler, finalize, read_checked, resume, shell, stamp
|
|
17
|
+
|
|
18
|
+
_ENDPOINTS = {
|
|
19
|
+
"application": "health",
|
|
20
|
+
"liveness": "health-liveness",
|
|
21
|
+
"readiness": "health-readiness",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@tool_handler
|
|
26
|
+
async def diagnose_health(
|
|
27
|
+
arguments: Mapping[str, Any], services: Services
|
|
28
|
+
) -> dict[str, Any]:
|
|
29
|
+
request_target = "local"
|
|
30
|
+
try:
|
|
31
|
+
query = validate_arguments(arguments, "springboot_diagnose_health")
|
|
32
|
+
request_target = query.target_id
|
|
33
|
+
assert isinstance(query, HealthQuery)
|
|
34
|
+
if query.cursor:
|
|
35
|
+
return resume(services, query)
|
|
36
|
+
endpoint = _ENDPOINTS[query.group]
|
|
37
|
+
snapshot = uuid4().hex
|
|
38
|
+
observation = await read_checked(services, query.target_id, endpoint, snapshot)
|
|
39
|
+
overall, items, gaps = normalize_health(
|
|
40
|
+
observation.body,
|
|
41
|
+
component_path=query.component_path,
|
|
42
|
+
include_details=query.include_details,
|
|
43
|
+
include_healthy=query.include_healthy,
|
|
44
|
+
max_depth=query.max_depth,
|
|
45
|
+
)
|
|
46
|
+
result = shell(query.target_id, "registered target health", "health-1")
|
|
47
|
+
stamp(result, observation, endpoint, snapshot)
|
|
48
|
+
for item in items:
|
|
49
|
+
item["endpointId"] = endpoint
|
|
50
|
+
item["observationId"] = observation.observation_id
|
|
51
|
+
result["data"] = {
|
|
52
|
+
"overallStatus": overall,
|
|
53
|
+
"activeGroup": query.group,
|
|
54
|
+
"items": items,
|
|
55
|
+
}
|
|
56
|
+
limited = {"HEALTH_DETAILS_LIMIT", "HEALTH_DEPTH_LIMIT"} & set(gaps)
|
|
57
|
+
if limited:
|
|
58
|
+
result["truncated"] = True
|
|
59
|
+
result["truncationReasons"] = sorted(
|
|
60
|
+
set(result["truncationReasons"] + list(limited))
|
|
61
|
+
)[:8]
|
|
62
|
+
incomplete_count = {
|
|
63
|
+
"HEALTH_DETAILS_NOT_VISIBLE",
|
|
64
|
+
"HEALTH_COMPONENT_UNREADABLE",
|
|
65
|
+
"HEALTH_STATUS_UNOBSERVED",
|
|
66
|
+
} & set(gaps)
|
|
67
|
+
result["totalMatched"] = None if limited or incomplete_count else len(items)
|
|
68
|
+
partial(result, gaps)
|
|
69
|
+
if "HEALTH_DETAILS_NOT_VISIBLE" in result["coverage"]["gaps"]:
|
|
70
|
+
result["nextAction"] = {
|
|
71
|
+
"kind": "remediate",
|
|
72
|
+
"reasonCode": "HEALTH_DETAILS_NOT_VISIBLE",
|
|
73
|
+
"filterSuggestions": [],
|
|
74
|
+
}
|
|
75
|
+
return finalize(result, services, query)
|
|
76
|
+
except NormalizationError as exc:
|
|
77
|
+
return failed(
|
|
78
|
+
exc.code,
|
|
79
|
+
request_target,
|
|
80
|
+
endpoint=exc.endpoint_id if isinstance(exc, EndpointReadError) else None,
|
|
81
|
+
http_status=exc.http_status if isinstance(exc, EndpointReadError) else None,
|
|
82
|
+
)
|
|
83
|
+
except TimeoutError:
|
|
84
|
+
return failed("TARGET_TIMEOUT", request_target)
|
|
85
|
+
except (ValueError, TypeError, KeyError, RecursionError, UnicodeError):
|
|
86
|
+
return failed("MALFORMED_EVIDENCE", request_target)
|
|
87
|
+
except Exception:
|
|
88
|
+
return failed("INTERNAL_ERROR", request_target)
|