Skip to content

Commit c097bc9

Browse files
committed
Harden Home Assistant E2E diagnostics
1 parent 0290eb4 commit c097bc9

3 files changed

Lines changed: 92 additions & 15 deletions

File tree

scripts/e2e_home_assistant.py

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
import json
88
import shutil
99
import subprocess
10+
import sys
1011
import tempfile
1112
import time
1213
import urllib.request
1314
import uuid
14-
from collections.abc import Mapping, Sequence
15+
from collections.abc import Callable, Mapping, Sequence
1516
from dataclasses import dataclass
1617
from pathlib import Path
1718

@@ -70,11 +71,18 @@ def docker_logs(container: str) -> str:
7071
return result.stdout + result.stderr
7172

7273

73-
def wait_for_http(url: str, *, timeout: float) -> bytes:
74+
def wait_for_http(
75+
url: str,
76+
*,
77+
timeout: float,
78+
assert_live: Callable[[], None] | None = None,
79+
) -> bytes:
7480
"""Wait until an HTTP endpoint responds successfully or fail with context."""
7581
deadline = time.monotonic() + timeout
7682
last_error: Exception | None = None
7783
while time.monotonic() < deadline:
84+
if assert_live is not None:
85+
assert_live()
7886
try:
7987
with urllib.request.urlopen(url, timeout=5) as response:
8088
body: object = response.read()
@@ -92,7 +100,9 @@ def assert_running(container: str) -> None:
92100
"""Assert that Docker still considers a container alive."""
93101
state = docker("inspect", "--format", "{{.State.Running}}", container)
94102
if state != "true":
95-
raise AssertionError(f"container {container} is not running")
103+
raise AssertionError(
104+
f"container {container} is not running\nlogs:\n{docker_logs(container)}"
105+
)
96106

97107

98108
def write_json(path: Path, value: Mapping[str, object]) -> None:
@@ -242,7 +252,11 @@ def assetlink_sites(document: object) -> list[str]:
242252
def assert_initial_addon_behavior(runtime: Runtime) -> None:
243253
"""Verify readiness, HA reachability, DAL serving, and tunnel arguments."""
244254
metrics_url = f"http://127.0.0.1:{published_port(runtime.addon, 36500)}/metrics"
245-
metrics = wait_for_http(metrics_url, timeout=120).decode()
255+
metrics = wait_for_http(
256+
metrics_url,
257+
timeout=120,
258+
assert_live=lambda: assert_running(runtime.addon),
259+
).decode()
246260
if "cloudflared_e2e_up 1" not in metrics:
247261
raise AssertionError(f"unexpected metrics payload: {metrics}")
248262

@@ -275,6 +289,7 @@ def assert_restart_and_reconfiguration(runtime: Runtime) -> None:
275289
wait_for_http(
276290
f"http://127.0.0.1:{published_port(runtime.addon, 36500)}/metrics",
277291
timeout=120,
292+
assert_live=lambda: assert_running(runtime.addon),
278293
)
279294
assert_running(runtime.addon)
280295

@@ -289,6 +304,7 @@ def assert_restart_and_reconfiguration(runtime: Runtime) -> None:
289304
wait_for_http(
290305
f"http://127.0.0.1:{published_port(runtime.addon, 36500)}/metrics",
291306
timeout=120,
307+
assert_live=lambda: assert_running(runtime.addon),
292308
)
293309
assert_running(runtime.addon)
294310
if (runtime.addon_data / "digital-asset-links").exists():
@@ -385,11 +401,23 @@ def collect_artifacts(
385401
)
386402

387403

388-
def cleanup(runtime: Runtime) -> None:
404+
def cleanup(settings: Settings, runtime: Runtime) -> None:
389405
"""Remove E2E resources independently and idempotently."""
390406
for container in (runtime.invalid_addon, runtime.addon, runtime.home_assistant):
391407
docker("rm", "--force", container, check=False)
392408
docker("network", "rm", runtime.network, check=False)
409+
docker(
410+
"run",
411+
"--rm",
412+
"--volume",
413+
f"{runtime.root}:/cleanup",
414+
"--entrypoint",
415+
"/bin/rm",
416+
settings.addon_image,
417+
"-rf",
418+
"/cleanup",
419+
check=False,
420+
)
393421

394422

395423
def parse_settings() -> Settings:
@@ -413,7 +441,10 @@ def parse_settings() -> Settings:
413441

414442
def main() -> None:
415443
settings = parse_settings()
416-
with tempfile.TemporaryDirectory(prefix="hass-cloudflared-e2e-") as temporary:
444+
with tempfile.TemporaryDirectory(
445+
prefix="hass-cloudflared-e2e-",
446+
ignore_cleanup_errors=True,
447+
) as temporary:
417448
runtime = create_runtime(Path(temporary))
418449
version: str | None = None
419450
try:
@@ -424,9 +455,23 @@ def main() -> None:
424455
assert_invalid_config_is_rejected(settings, runtime)
425456
assert_running(runtime.home_assistant)
426457
print(f"E2E passed against Home Assistant {version}")
458+
except Exception:
459+
for label, container in (
460+
("Home Assistant", runtime.home_assistant),
461+
("add-on", runtime.addon),
462+
("invalid add-on", runtime.invalid_addon),
463+
):
464+
print(
465+
f"\n--- {label} logs ---\n{docker_logs(container)}",
466+
file=sys.stderr,
467+
)
468+
raise
427469
finally:
428-
collect_artifacts(settings, runtime, version=version)
429-
cleanup(runtime)
470+
try:
471+
collect_artifacts(settings, runtime, version=version)
472+
except (OSError, RuntimeError) as error:
473+
print(f"Unable to collect all E2E artifacts: {error}", file=sys.stderr)
474+
cleanup(settings, runtime)
430475

431476

432477
if __name__ == "__main__":

tests/e2e/mock_cloudflared.sh

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,28 @@ set -eu
44
printf '%s\n' "$@" > /data/e2e-cloudflared-args
55
mkdir -p /data/e2e-metrics
66

7-
wget -q -O /data/e2e-home-assistant-root http://homeassistant:8123/
8-
if ! wget -q -O /data/e2e-assetlinks \
9-
http://127.0.0.1:36555/.well-known/assetlinks.json; then
10-
rm -f /data/e2e-assetlinks
11-
fi
12-
137
cat > /data/e2e-metrics/metrics <<'EOF'
148
# HELP cloudflared_e2e_up Whether the deterministic E2E tunnel double is ready.
159
# TYPE cloudflared_e2e_up gauge
1610
cloudflared_e2e_up 1
1711
EOF
1812

19-
exec busybox-extras httpd -f -p 0.0.0.0:36500 -h /data/e2e-metrics
13+
busybox-extras httpd -f -p 0.0.0.0:36500 -h /data/e2e-metrics &
14+
metrics_pid=$!
15+
trap 'kill "${metrics_pid}" 2>/dev/null || true' EXIT INT TERM
16+
17+
attempt=0
18+
until wget -q -O /data/e2e-home-assistant-root http://homeassistant:8123/; do
19+
attempt=$((attempt + 1))
20+
if [ "${attempt}" -ge 60 ]; then
21+
echo "Home Assistant did not become reachable from the add-on" >&2
22+
exit 1
23+
fi
24+
sleep 1
25+
done
26+
if ! wget -q -O /data/e2e-assetlinks \
27+
http://127.0.0.1:36555/.well-known/assetlinks.json; then
28+
rm -f /data/e2e-assetlinks
29+
fi
30+
31+
wait "${metrics_pid}"

tests/test_e2e_home_assistant.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,23 @@ def open_with_startup_reset(url: str, *, timeout: float) -> SuccessfulResponse:
9292

9393
assert wait_for_http("http://home-assistant.test", timeout=1) == b"ready"
9494
assert attempts == 2
95+
96+
97+
def test_wait_for_http_fails_immediately_when_process_exits(
98+
monkeypatch: pytest.MonkeyPatch,
99+
) -> None:
100+
def reject_request(url: str, *, timeout: float) -> SuccessfulResponse:
101+
del url, timeout
102+
raise AssertionError("HTTP must not be attempted after process exit")
103+
104+
def assert_live() -> None:
105+
raise AssertionError("container exited")
106+
107+
monkeypatch.setattr("urllib.request.urlopen", reject_request)
108+
109+
with pytest.raises(AssertionError, match="container exited"):
110+
wait_for_http(
111+
"http://cloudflared.test/metrics",
112+
timeout=120,
113+
assert_live=assert_live,
114+
)

0 commit comments

Comments
 (0)