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
+34 -6
View File
@@ -48,14 +48,33 @@ def is_due(target, now: datetime) -> bool:
return elapsed >= target.interval_seconds - TICK_SECONDS / 2
def _token_source(name: str):
from steward.core.plugin_manager import loaded_plugin_submodule
return loaded_plugin_submodule(name, "metrics")
async def target_token(app: "Quart", target, url: str) -> str:
"""The token to ask `url` with: the target's own, or one from the plugin whose
sign-in it uses. Raises TokenUnavailable when that plugin has none to give."""
from steward.core.crypto import decrypt_secret
from steward.core.prometheus import TokenUnavailable
if not target.token_from:
return decrypt_secret(target.token, context=f"prometheus target {target.name} token")
source = _token_source(target.token_from)
if source is None:
raise TokenUnavailable(f"Its token comes from the {target.token_from} plugin, which isn't turned on.")
return await source.scrape_token(app, url)
async def scrape_due(app: "Quart", scrape=None) -> None:
"""Scrape every due target at once, then write each one's outcome to its
row and its series to the metric tables. `scrape` stands in for
scraper.scrape in tests."""
from steward.core.crypto import decrypt_secret
from steward.core.prometheus import TokenUnavailable
from .models import PrometheusTarget
from .scraper import ScrapeError
from .scraper import ScrapeError, TokenRefused
from .scraper import scrape as fetch
scrape = scrape or fetch
@@ -68,11 +87,15 @@ async def scrape_due(app: "Quart", scrape=None) -> None:
async def one(t):
started = time.monotonic()
token = decrypt_secret(t.token, context=f"prometheus target {t.name} token")
async def fetched():
return await scrape(t.url, await target_token(app, t, t.url), t.verify_tls)
try:
families = await asyncio.wait_for(scrape(t.url, token, t.verify_tls),
SCRAPE_DEADLINE_SECONDS)
families = await asyncio.wait_for(fetched(), SCRAPE_DEADLINE_SECONDS)
return families, (time.monotonic() - started) * 1000
except TokenUnavailable as exc:
return ScrapeError(str(exc)), (time.monotonic() - started) * 1000
except TimeoutError:
return ScrapeError(f"Gave up after {SCRAPE_DEADLINE_SECONDS} s."), \
(time.monotonic() - started) * 1000
@@ -81,6 +104,11 @@ async def scrape_due(app: "Quart", scrape=None) -> None:
results = await asyncio.gather(*(one(t) for t in targets))
await record(app, targets, results, datetime.now(timezone.utc))
# A refused sign-in is the lending plugin's to stop, so one Try again or sign-in
# there starts everything that uses it.
for t, (result, _) in zip(targets, results):
if t.token_from and isinstance(result, TokenRefused) and (source := _token_source(t.token_from)):
await source.token_refused(app)
async def forget_gone(session, target: str, kept: set[tuple[str, str]]) -> None:
@@ -132,7 +160,7 @@ async def record(app: "Quart", targets, results, now: datetime) -> None:
if not isinstance(result, ScrapeError):
logger.warning("Prometheus scrape of %s failed: %r", target.name, result)
row.last_error = str(result) or "The scrape failed."
row.refused = isinstance(result, TokenRefused)
row.refused = isinstance(result, TokenRefused) and not row.token_from
batch.add(source_module=SOURCE, resource_name=row.name, metric_name=IS_UP,
value=0.0, recorded_at=now)
continue