feat(homeassistant): its page offers the metrics scrape under the same sign-in, so one user covers everything (#6744)
CI / lint (push) Successful in 1s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m58s
CI / publish (push) Successful in 1m13s

A Prometheus target's token_from names the plugin that supplies its token at each
scrape. Read its metrics makes the target; Use this sign-in moves one at that address
across, keeping its name, filters and history. A refusal stops the connection, so one
Try again or sign-in starts everything, and the sign-in is only sent to its own address.
A 404 says how to turn on Home Assistant's Prometheus integration, as Steward doesn't
change Home Assistant. Refused also joins the volatile columns a backup check ignores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 12:06:55 -05:00
co-authored by Claude Opus 5.5
parent 66a5dd12fd
commit 4c9ed02652
19 changed files with 482 additions and 24 deletions
+168
View File
@@ -50,6 +50,7 @@ class FakeHomeAssistant:
self.admin = True
self.reject_install = None
self.cut_off = False
self.no_metrics = False
self.entities = {
ADDON: self._update("on", "1.0"),
"sensor.backup_last_successful_automatic_backup": {"state": BEFORE, "attributes": {}},
@@ -84,6 +85,10 @@ class FakeHomeAssistant:
self.tokens.add(request.headers.get("authorization"))
if self.refuse:
return httpx.Response(401)
if path == "/api/prometheus":
if self.no_metrics:
return httpx.Response(404)
return httpx.Response(200, text="# TYPE homeassistant_entity_x gauge\nhomeassistant_entity_x 1\n")
if path == "/api/template":
return httpx.Response(200, text="1") if self.admin else httpx.Response(401)
if path.startswith("/api/services/"):
@@ -464,3 +469,166 @@ def test_signing_in_again_ends_the_sign_in_it_replaces(app, monkeypatch):
connection = asyncio.run(go())
assert ha.revoked == ["refresh-old"] and not connection.refused and connection.last_polled_at is None
# ── Its metrics, under the one sign-in ───────────────────────────────────────
def _prom(sub):
from steward.core.plugin_manager import loaded_plugin_submodule
return loaded_plugin_submodule("prometheus", sub)
METRICS_URL = "http://ha.test:8123/api/prometheus"
@pytest.fixture
def signed_in(app, monkeypatch):
"""A fake Home Assistant behind both the plugin's client and the scraper."""
client, scraper = _ha("client"), _prom("scraper")
ha = FakeHomeAssistant()
real_token, real_scrape = client.access_token, scraper.scrape
monkeypatch.setattr(client, "access_token", lambda c: real_token(c, ha.transport))
monkeypatch.setattr(scraper, "scrape", lambda url, token, verify: real_scrape(url, token, verify, ha.transport))
return ha
async def _no_targets(app):
T = _prom("models").PrometheusTarget
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(delete(T).where(T.url.like("%ha.test%") | (T.name == "HomeAssistant")))
async def _the_target(app):
T = _prom("models").PrometheusTarget
async with app.db_sessionmaker() as s:
return (await s.execute(select(T).where(T.url.like("%ha.test%")))).scalars().first()
def test_reading_its_metrics_makes_a_target_that_scrapes_with_the_sign_in(app, signed_in):
sched = _prom("scheduler")
sent = []
async def fake_scrape(url, token, verify_tls):
sent.append((url, token))
return await _prom("scraper").scrape(url, token, verify_tls)
async def go():
await _clean(app)
await _no_targets(app)
web, _ = await _admin_client(app)
page = await (await web.get("/plugins/homeassistant/")).get_data(as_text=True)
assert "Read its metrics" in page
response = await web.post("/plugins/homeassistant/metrics", headers=CSRF)
assert response.status_code == 302
await sched.scrape_due(app, scrape=fake_scrape)
page = await (await web.get("/plugins/homeassistant/")).get_data(as_text=True)
assert "Read with this sign-in" in page
return await _the_target(app)
target = asyncio.run(go())
assert target.name == "HomeAssistant" and target.token == "" and target.token_from == "homeassistant"
assert "python_*" in target.drop and target.last_error is None and target.last_ok_at
assert sent == [(METRICS_URL, "access-1")]
def test_using_the_sign_in_moves_a_target_and_keeps_its_name_and_filters(app, signed_in):
from steward.core.crypto import encrypt_secret
T = _prom("models").PrometheusTarget
async def go():
await _clean(app)
await _no_targets(app)
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(T(name="my-house", url=METRICS_URL, token=encrypt_secret("dead-token"), keep=["a_*"], refused=True))
web, _ = await _admin_client(app)
page = await (await web.get("/plugins/homeassistant/")).get_data(as_text=True)
assert "Use this sign-in" in page
assert (await web.post("/plugins/homeassistant/metrics", headers=CSRF)).status_code == 302
target = await _the_target(app)
await _no_targets(app)
return target
target = asyncio.run(go())
assert (target.name, target.keep) == ("my-house", ["a_*"])
assert target.token == "" and target.token_from == "homeassistant" and not target.refused
def test_metrics_home_assistant_doesnt_serve_change_nothing_and_say_how(app, signed_in):
signed_in.no_metrics = True
async def go():
await _clean(app)
await _no_targets(app)
web, _ = await _admin_client(app)
response = await web.post("/plugins/homeassistant/metrics", headers=CSRF)
return response.status_code, await response.get_data(as_text=True), await _the_target(app)
status, page, target = asyncio.run(go())
assert status == 409 and "configuration.yaml" in page and target is None
def test_a_refused_scrape_stops_the_sign_in_and_try_again_starts_both(app, signed_in):
sched, metrics = _prom("scheduler"), _ha("metrics")
TokenRefused = _prom("scraper").TokenRefused
T = _prom("models").PrometheusTarget
asked = []
async def refusing(url, token, verify_tls):
asked.append(url)
raise TokenRefused("Answered 401: the token was refused.")
async def overdue():
async with app.db_sessionmaker() as s:
async with s.begin():
row = await s.get(T, (await _the_target(app)).id)
row.last_scraped_at = row.last_scraped_at.replace(year=2000)
async def go():
await _clean(app)
await _no_targets(app)
web, _ = await _admin_client(app)
await web.post("/plugins/homeassistant/metrics", headers=CSRF)
await sched.scrape_due(app, scrape=refusing)
connection = (await _state(app))[0]
assert connection.refused and connection.last_error == metrics.REFUSED
# Nothing more is sent, by the scrape or the poll, and the target says why.
await overdue()
await sched.scrape_due(app, scrape=refusing)
assert len(asked) == 1
target = await _the_target(app)
assert not target.refused and "Try again" in target.last_error
assert (await web.post("/plugins/homeassistant/connection/retry", headers=CSRF)).status_code == 302
await overdue()
await sched.scrape_due(app, scrape=refusing)
assert len(asked) == 2
await _no_targets(app)
asyncio.run(go())
def test_the_sign_in_isnt_sent_to_another_address(app, signed_in):
sched = _prom("scheduler")
T = _prom("models").PrometheusTarget
asked = []
async def fake_scrape(url, token, verify_tls):
asked.append(url)
return {}
async def go():
await _clean(app)
await _no_targets(app)
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(T(name="elsewhere", url="http://elsewhere.ha.test/metrics", token_from="homeassistant"))
await sched.scrape_due(app, scrape=fake_scrape)
target = await _the_target(app)
await _no_targets(app)
return target
target = asyncio.run(go())
assert asked == [] and "isn't the address" in target.last_error and signed_in.refreshes == []