diff --git a/services/bstein-dev-home/av-observer/deployment.yaml b/services/bstein-dev-home/av-observer/deployment.yaml index f8bd07e0..8ee3a2b1 100644 --- a/services/bstein-dev-home/av-observer/deployment.yaml +++ b/services/bstein-dev-home/av-observer/deployment.yaml @@ -55,10 +55,8 @@ spec: requests: {cpu: 10m, memory: 16Mi} limits: {cpu: 100m, memory: 64Mi} readinessProbe: - exec: - command: [python, -c, "import socket; socket.create_connection(('127.0.0.1', 8081), 2).close()"] - # Starting an interpreter under the ARM CPU quota can exceed the - # default one-second exec timeout, even when the listener is healthy. + # Probe through nginx; the receipt listener remains loopback-only. + httpGet: {path: /healthz, port: 8080} timeoutSeconds: 5 periodSeconds: 15 volumeMounts: diff --git a/services/bstein-dev-home/av-observer/nginx.conf b/services/bstein-dev-home/av-observer/nginx.conf index f4025d97..6ec27a74 100644 --- a/services/bstein-dev-home/av-observer/nginx.conf +++ b/services/bstein-dev-home/av-observer/nginx.conf @@ -29,6 +29,14 @@ http { location = /lesavka-av-test { return 308 /av-test/; } location = /lesavka-av-test/ { return 308 /av-test/; } location = /lesavka-av-test/index.html { return 308 /av-test/; } + # Kubelet checks the running listener without spawning Python each time. + # This path is outside the observer's public ingress prefixes. + location = /healthz { + proxy_pass http://127.0.0.1:8081/healthz; + proxy_connect_timeout 2s; + proxy_read_timeout 2s; + proxy_pass_request_headers off; + } location = /av-test { return 308 /av-test/; } location = /av-test/ { if ($request_method !~ ^(GET|HEAD)$) { return 405; } diff --git a/services/bstein-dev-home/av-observer/report-service/server.py b/services/bstein-dev-home/av-observer/report-service/server.py index 7f4074fa..58d546bf 100644 --- a/services/bstein-dev-home/av-observer/report-service/server.py +++ b/services/bstein-dev-home/av-observer/report-service/server.py @@ -28,6 +28,9 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(body) def do_GET(self): + """Answer the internal readiness check without reading receipt state.""" + if self.path == "/healthz": + return self.reply(204) self.reply(405) do_HEAD = do_GET diff --git a/testing/tests/test_av_observer_health.py b/testing/tests/test_av_observer_health.py new file mode 100644 index 00000000..565c5d26 --- /dev/null +++ b/testing/tests/test_av_observer_health.py @@ -0,0 +1,41 @@ +"""Exercise readiness over HTTP without exposing receipt operations or content.""" + +import importlib.util +from pathlib import Path +import threading +import urllib.error +import urllib.request + +import pytest + + +def test_health_is_read_only_and_does_not_open_other_get_paths(monkeypatch, capfd): + """A real listener answers health, keeps API GET closed, and emits no logs.""" + source = (Path(__file__).resolve().parents[2] / "services/bstein-dev-home" + / "av-observer/report-service/server.py") + monkeypatch.syspath_prepend(str(source.parent)) + spec = importlib.util.spec_from_file_location("observer_health_server", source) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + # An object without receipt methods fails if health ever accesses that state. + server = module.Server(("127.0.0.1", 0), "https://example.invalid", object()) + worker = threading.Thread(target=server.serve_forever, daemon=True) + worker.start() + client = urllib.request.build_opener(urllib.request.ProxyHandler({})) + root = f"http://127.0.0.1:{server.server_port}" + try: + with client.open(root + "/healthz", timeout=2) as response: + assert response.status == 204 + assert response.read() == b"" + with pytest.raises(urllib.error.HTTPError) as error: + client.open(root + "/av-test/api/start", timeout=2) + assert error.value.code == 405 + with pytest.raises(urllib.error.HTTPError) as error: + client.open(urllib.request.Request(root + "/av-test/api/start", data=b"{}"), timeout=2) + assert error.value.code == 403 + finally: + server.shutdown() + server.server_close() + worker.join(timeout=2) + captured = capfd.readouterr() + assert not captured.out and not captured.err