Skip to content

Commit 7cdee43

Browse files
fix: tighten HAOS cache guards and visibility logs (#2240)
* fix: tighten HAOS cache guards and visibility logs * fix: address follow-up review findings * Test HAOS cache keys per workflow job
1 parent e82408d commit 7cdee43

3 files changed

Lines changed: 168 additions & 23 deletions

File tree

src/ha_mcp/visibility/enforcement.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
import re
5050
import time
5151
from collections.abc import Callable
52+
from contextvars import ContextVar
5253
from typing import TYPE_CHECKING, Any, NoReturn
5354

5455
from fastmcp.exceptions import ToolError
@@ -72,6 +73,10 @@
7273

7374
logger = logging.getLogger(__name__)
7475

76+
_config_load_warning_emitted: ContextVar[bool | None] = ContextVar(
77+
"visibility_config_load_warning_emitted", default=None
78+
)
79+
7580
# The resolved hidden set is cached with a short TTL so a burst of tool calls
7681
# does not re-fetch the registry each time; a config edit invalidates it sooner
7782
# (the cache is keyed on the hide dimensions), so the ~30s window only applies to
@@ -353,6 +358,10 @@ async def _active_config(
353358
loaded successfully — for the common non-enforce install that preserves
354359
availability, and for an enforce install it preserves the boundary.
355360
With no good load ever, fail CLOSED like PolicyMiddleware does.
361+
362+
Each half diagnoses a distinct config-load failure. A context-local
363+
marker suppresses only the second traceback when both halves fail during
364+
the same call.
356365
"""
357366
config: VisibilityConfig | None
358367
try:
@@ -369,12 +378,15 @@ async def _active_config(
369378
)
370379
else:
371380
outcome = "failing closed"
372-
if tool_name != _REPORT_ISSUE_TOOL or emit_report_issue_diagnostic:
381+
warning_emitted = _config_load_warning_emitted.get()
382+
if warning_emitted is not True:
373383
logger.warning(
374384
"visibility enforce: config load failed; %s",
375385
outcome,
376386
exc_info=True,
377387
)
388+
if warning_emitted is not None:
389+
_config_load_warning_emitted.set(True)
378390
# ``ha_report_issue`` is an unconditional diagnostic escape hatch while
379391
# the toggle is off, not only a registry-failure fallback. A missing or
380392
# corrupt first config load follows that safe default; a last-known-good
@@ -423,6 +435,15 @@ class VisibilityInboundEnforcement(_VisibilityEnforcementBase):
423435

424436
async def on_call_tool(
425437
self, context: MiddlewareContext, call_next: CallNext
438+
) -> Any:
439+
token = _config_load_warning_emitted.set(False)
440+
try:
441+
return await self._on_call_tool(context, call_next)
442+
finally:
443+
_config_load_warning_emitted.reset(token)
444+
445+
async def _on_call_tool(
446+
self, context: MiddlewareContext, call_next: CallNext
426447
) -> Any:
427448
name = context.message.name
428449
args = context.message.arguments or {}

tests/src/unit/test_haos_image_workflow_shape.py

Lines changed: 96 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@
77

88
_REPO_ROOT = Path(__file__).resolve().parents[3]
99
_WORKFLOW_DIR = _REPO_ROOT / ".github" / "workflows"
10-
_CACHE_KEY_CONSUMERS = (
11-
"build-haos-test-image.yml",
12-
"haos-e2e-tests.yml",
13-
"haos-e2e-embedded-tests.yml",
14-
"haos-e2e-inaddon-tests.yml",
15-
"haos-e2e-stdio-tests.yml",
16-
)
10+
_CACHE_KEY_CONSUMER_FLOOR = 5
11+
_CACHE_KEY_OUTPUT_MARKER = "cache-key=haos-image-"
12+
_HAOS_IMAGE_CACHE_PATH = "/tmp/haos-test-image.qcow2"
13+
_CACHE_ACTIONS = {
14+
"actions/cache",
15+
"actions/cache/restore",
16+
"actions/cache/save",
17+
}
1718
_CACHE_KEY_COMMAND = """hash=$(git ls-tree -r HEAD \\
1819
tests/haos_image_build \\
1920
tests/initial_test_state \\
@@ -28,30 +29,106 @@ def _workflow(path: Path) -> dict[str, Any]:
2829
return yaml.safe_load(path.read_text(encoding="utf-8"))
2930

3031

31-
def _cache_key_command(path: Path) -> str:
32-
workflow = _workflow(path)
33-
steps = [
32+
def _job_steps(job: dict[str, Any]) -> list[dict[str, Any]]:
33+
return [step for step in job.get("steps", []) if isinstance(step, dict)]
34+
35+
36+
def _cache_key_steps(job: dict[str, Any]) -> list[dict[str, Any]]:
37+
return [
3438
step
35-
for job in workflow["jobs"].values()
36-
for step in job.get("steps", [])
37-
if "image cache key" in str(step.get("name", "")).lower()
39+
for step in _job_steps(job)
40+
if _CACHE_KEY_OUTPUT_MARKER in str(step.get("run", ""))
41+
]
42+
43+
44+
def _uses_haos_image_cache(job: dict[str, Any]) -> bool:
45+
return any(
46+
str(step.get("uses", "")).partition("@")[0] in _CACHE_ACTIONS
47+
and _HAOS_IMAGE_CACHE_PATH
48+
in str(step.get("with", {}).get("path", "")).splitlines()
49+
for step in _job_steps(job)
50+
)
51+
52+
53+
def _cache_key_consumers(
54+
workflow_dir: Path = _WORKFLOW_DIR,
55+
) -> list[tuple[Path, str]]:
56+
workflow_paths = sorted((*workflow_dir.glob("*.yml"), *workflow_dir.glob("*.yaml")))
57+
consumers = [
58+
(path, str(job_id))
59+
for path in workflow_paths
60+
for job_id, job in _workflow(path)["jobs"].items()
61+
if isinstance(job, dict) and _uses_haos_image_cache(job)
3862
]
39-
assert len(steps) == 1, f"{path.name} must have one image cache-key step"
63+
assert len(consumers) >= _CACHE_KEY_CONSUMER_FLOOR, (
64+
"expected at least "
65+
f"{_CACHE_KEY_CONSUMER_FLOOR} HAOS image cache-key consumers, found "
66+
f"{[(path.name, job_id) for path, job_id in consumers]}"
67+
)
68+
return consumers
69+
70+
71+
def _cache_key_command(path: Path, job_id: str) -> str:
72+
consumer = f"{path.name}:{job_id}"
73+
job = _workflow(path)["jobs"][job_id]
74+
assert isinstance(job, dict), f"{consumer} must be a job mapping"
75+
steps = _cache_key_steps(job)
76+
assert len(steps) == 1, f"{consumer} must have one image cache-key step"
4077
script = str(steps[0]["run"])
4178
start_marker = "hash=$(git ls-tree -r HEAD"
4279
end_marker = 'echo "cache-key=haos-image-$hash" >> "$GITHUB_OUTPUT"\n'
4380
assert script.count(start_marker) == 1, (
44-
f"{path.name} must have one cache-key command"
81+
f"{consumer} must have one cache-key command"
4582
)
4683
assert script.count(end_marker) == 1, (
47-
f"{path.name} must emit one HAOS image cache key"
84+
f"{consumer} must emit one HAOS image cache key"
4885
)
4986
start = script.index(start_marker)
5087
end = script.index(end_marker, start) + len(end_marker)
5188
return script[start:end]
5289

5390

5491
def test_haos_image_cache_key_command_matches_every_consumer() -> None:
55-
for filename in _CACHE_KEY_CONSUMERS:
56-
path = _WORKFLOW_DIR / filename
57-
assert _cache_key_command(path) == _CACHE_KEY_COMMAND, filename
92+
for path, job_id in _cache_key_consumers():
93+
consumer = f"{path.name}:{job_id}"
94+
assert _cache_key_command(path, job_id) == _CACHE_KEY_COMMAND, consumer
95+
96+
97+
def test_cache_key_consumer_discovery_is_marker_independent(tmp_path: Path) -> None:
98+
workflow = """jobs:
99+
lane:
100+
steps:
101+
- uses: actions/cache/restore@pinned
102+
with:
103+
path: /tmp/haos-test-image.qcow2
104+
key: shared
105+
"""
106+
expected = []
107+
for index in range(_CACHE_KEY_CONSUMER_FLOOR):
108+
path = tmp_path / f"lane-{index}.yaml"
109+
path.write_text(workflow, encoding="utf-8")
110+
expected.append((path, "lane"))
111+
112+
assert _cache_key_consumers(tmp_path) == expected
113+
114+
115+
def test_cache_key_consumer_discovery_tracks_jobs_individually(
116+
tmp_path: Path,
117+
) -> None:
118+
path = tmp_path / "multi-lane.yaml"
119+
jobs = {
120+
f"lane-{index}": {
121+
"steps": [
122+
{
123+
"uses": "actions/cache/restore@pinned",
124+
"with": {"path": _HAOS_IMAGE_CACHE_PATH, "key": "shared"},
125+
}
126+
]
127+
}
128+
for index in range(_CACHE_KEY_CONSUMER_FLOOR)
129+
}
130+
path.write_text(yaml.safe_dump({"jobs": jobs}), encoding="utf-8")
131+
132+
assert _cache_key_consumers(tmp_path) == [
133+
(path, f"lane-{index}") for index in range(_CACHE_KEY_CONSUMER_FLOOR)
134+
]

tests/src/unit/visibility/test_enforcement.py

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,7 @@ async def test_corrupt_config_with_no_prior_load_keeps_report_issue_available(
469469
assert not any("failing closed" in message for message in messages), messages
470470

471471
async def test_corrupt_config_after_enforce_off_load_passes_through(
472-
self, breakable_config
472+
self, breakable_config, caplog
473473
):
474474
# Availability: a non-enforce install whose file corrupts mid-session
475475
# keeps working on the last-known-good (enforce-off) config.
@@ -480,11 +480,58 @@ async def test_corrupt_config_after_enforce_off_load_passes_through(
480480
)
481481
assert result.content[0].text == "first"
482482
breakable_config["broken"] = True
483-
result = await mw.on_call_tool(
483+
caplog.clear()
484+
with caplog.at_level("WARNING", logger="ha_mcp.visibility.enforcement"):
485+
result = await mw.on_call_tool(
486+
make_context("ha_get_state", {"entity_id": "light.any"}),
487+
_returns(text_result("second")),
488+
)
489+
assert result.content[0].text == "second"
490+
warnings = [
491+
record
492+
for record in caplog.records
493+
if "config load failed" in record.message
494+
]
495+
assert [record.message for record in warnings] == [
496+
"visibility enforce: config load failed; using last-known-good config"
497+
]
498+
assert warnings[0].exc_info is not None
499+
500+
async def test_outbound_only_config_load_failure_logs(
501+
self, breakable_config, monkeypatch, caplog
502+
):
503+
mw = make_mw(get_client=FakeClient)
504+
await mw.on_call_tool(
484505
make_context("ha_get_state", {"entity_id": "light.any"}),
485-
_returns(text_result("second")),
506+
_returns(text_result("prime")),
486507
)
508+
calls = 0
509+
510+
def _load(_d):
511+
nonlocal calls
512+
calls += 1
513+
if calls == 1:
514+
return breakable_config["config"]
515+
raise ValueError("entity_visibility.json changed during the call")
516+
517+
monkeypatch.setattr(resolver, "load_visibility_config", _load)
518+
caplog.clear()
519+
with caplog.at_level("WARNING", logger="ha_mcp.visibility.enforcement"):
520+
result = await mw.on_call_tool(
521+
make_context("ha_get_state", {"entity_id": "light.any"}),
522+
_returns(text_result("second")),
523+
)
524+
487525
assert result.content[0].text == "second"
526+
warnings = [
527+
record
528+
for record in caplog.records
529+
if "config load failed" in record.message
530+
]
531+
assert [record.message for record in warnings] == [
532+
"visibility enforce: config load failed; using last-known-good config"
533+
]
534+
assert warnings[0].exc_info is not None
488535

489536
async def test_corrupt_config_after_enforce_on_load_stays_enforced(
490537
self, breakable_config

0 commit comments

Comments
 (0)