feat(homeassistant): its page offers the metrics scrape under the same sign-in, so one user covers everything (#6744)
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:
@@ -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 == []
|
||||
|
||||
Reference in New Issue
Block a user