fix(prometheus): emit the health probe only where a backend answers it

The monitoring block a vhost includes ends in two locations that proxy to
the application's own backend, and resolve_upstream raises rather than
returning empty when no port exists to build that upstream from. A role
that renders a vhost without running a container of its own has none, so
including the block aborted its play. b64e4a0558 made web-svc-mirror the
first such role, four months after the assumption was written down.

The probe halves are now conditional. /healthz/live stays: it answers from
openresty, which is what liveness means for a role whose only artifact is
a vhost. /healthz/ready and /_live_probe go, because both proxy to a
backend that is not there. Guarding only the second one left the first
referencing a variable the guard no longer set, which is its own failure
and the reason this took two passes.

The condition asks a lookup rather than rebuilding the rule in the
template. Hand-derived, it tested the local port alone, which is correct
outside swarm and wrong inside it, where the upstream comes from the
internal port; web-app-matrix declares only that one and would have lost
its probe silently. The lookup reads both scopes and the port a role
resolved for itself, which is what resolve_upstream consumes.

No working endpoint is removed. A vhost rendering the block today must
already carry a port, or the lookup would be raising for it as well, so
the condition can only skip what could never have rendered. Not yet
rendered: the deploy that proves the template resolves has not run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-09-28 01:15:53 +02:00
1 parent 777ac4d595
commit 514a1fe74b
3 files changed
+149

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
"""Return whether an application has a backend a vhost can probe.
True when a port exists for ``resolve_upstream`` to build an upstream from:
the local one outside swarm, the internal one inside it. Asking that lookup
for a role that has neither raises rather than returning empty.
Usage in a template::
{% if lookup('probeable_backend', application_id) %}
...health probe against the app's own backend...
{% endif %}
Pass ``application_id`` as term 0, defaulting to the templating variable of the
same name. ``http_port`` and ``proxy_internal_port`` are read from the same
variables, so a role that resolved its port itself is not reported as
backendless. ``applications`` comes from ``lookup('applications')``, the
merged-config SPOT.
"""
from __future__ import annotations
from typing import Any
from ansible.plugins.loader import lookup_loader
from ansible.plugins.lookup import LookupBase
from utils.roles.entity.name import get_entity_name
PORT_SCOPES = ("local", "internal")
class LookupModule(LookupBase):
def run(
self,
terms: list[Any],
variables: dict[str, Any] | None = None,
**kwargs: Any,
) -> list[bool]:
"""Return ``[True]`` when a port exists to probe, ``[False]`` otherwise.
Args:
terms: term 0 is the application id.
variables: templating variables.
kwargs: unused.
"""
vars_ = variables or getattr(self._templar, "available_variables", {}) or {}
if vars_.get("http_port") or vars_.get("proxy_internal_port"):
return [True]
if terms and isinstance(terms[0], str):
application_id = terms[0]
else:
application_id = vars_.get("application_id", "")
if not application_id:
return [False]
applications = lookup_loader.get(
"applications",
loader=self._loader,
templar=getattr(self, "_templar", None),
).run([], variables=vars_)[0]
entity = get_entity_name(application_id)
ports = (
applications.get(application_id, {})
.get("services", {})
.get(entity, {})
.get("ports", {})
)
if not isinstance(ports, dict):
return [False]
return [
any(
(ports.get(scope) or {}).get("http")
for scope in PORT_SCOPES
if isinstance(ports.get(scope), dict)
)
]
@@ -28,6 +28,8 @@
end
end
{% set _probe_backend = lookup('probeable_backend', application_id) %}
{% if _probe_backend %}
-- Step 2: TCP/HTTP reachability — catches crashed or exited containers.
if alive then
local res = ngx.location.capture("/_live_probe")
@@ -35,6 +37,7 @@
alive = false
end
end
{% endif %}
if alive then
ngx.status = ngx.HTTP_OK
@@ -48,6 +51,7 @@
}
}
{% if _probe_backend %}
{% set prometheus_backend = lookup('proxy_pass', application_id, proxy_service_key | default(application_id | get_entity_name), 'http', tail='/', local_port=http_port, internal_port=proxy_internal_port | default('')) %}
location = /_live_probe {
internal;
@@ -66,3 +70,4 @@
proxy_read_timeout 5s;
proxy_intercept_errors off;
}
{% endif %}
@@ -0,0 +1,64 @@
from __future__ import annotations
import unittest
from unittest import mock
from unittest.mock import patch
from ansible.plugins.loader import lookup_loader
from . import PROJECT_ROOT # noqa: F401
class _DummyTemplar:
def __init__(self, available_vars: dict):
self.available_variables = available_vars
class ProbeableBackendTests(unittest.TestCase):
def setUp(self) -> None:
self.lookup = lookup_loader.get("probeable_backend")
self.lookup._templar = _DummyTemplar({})
self.lookup._loader = mock.MagicMock()
def _run(self, terms, applications: dict, **vars_):
class _StubApplications:
def run(self, terms_, variables=None, **kw):
return [applications]
with patch.object(lookup_loader, "get", return_value=_StubApplications()):
return self.lookup.run(terms, variables=vars_)
def test_a_role_without_any_port_has_nothing_to_probe(self) -> None:
apps = {"web-svc-mirror": {"services": {"mirror": {}}}}
self.assertEqual(self._run(["web-svc-mirror"], apps), [False])
def test_a_local_port_is_probeable(self) -> None:
apps = {
"web-app-foo": {"services": {"foo": {"ports": {"local": {"http": 8080}}}}}
}
self.assertEqual(self._run(["web-app-foo"], apps), [True])
def test_an_internal_port_alone_is_probeable(self) -> None:
apps = {
"web-app-foo": {
"services": {"foo": {"ports": {"internal": {"http": 8008}}}}
}
}
self.assertEqual(
self._run(["web-app-foo"], apps),
[True],
"swarm resolves the upstream from the internal port, so a role "
"carrying only that one still has a backend to probe",
)
def test_a_port_the_role_resolved_itself_counts(self) -> None:
apps = {"web-app-foo": {"services": {"foo": {}}}}
self.assertEqual(self._run(["web-app-foo"], apps, http_port="8021"), [True])
if __name__ == "__main__":
unittest.main()