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
@@ -0,0 +1,23 @@
"""A target can take its token from another plugin's sign-in
Revision ID: prometheus_004_token_from
Revises: prometheus_003_refused
Create Date: 2026-11-01
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "prometheus_004_token_from"
down_revision: Union[str, None] = "prometheus_003_refused"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("prometheus_targets", sa.Column("token_from", sa.String(50), nullable=True))
def downgrade() -> None:
op.drop_column("prometheus_targets", "token_from")
+3 -1
View File
@@ -13,7 +13,8 @@ from steward.models.base import Base
class PrometheusTarget(Base):
"""A /metrics endpoint Steward scrapes, edited on the Prometheus page.
The bearer token is stored encrypted. keep and drop are lists of name
The bearer token is stored encrypted, or `token_from` names the plugin whose
sign-in supplies it at each scrape (its metrics.scrape_token). keep and drop are lists of name
patterns ("fabledarchives_*") an admin chooses: Steward does the
filtering, the endpoint serves everything. The last_* columns are the
newest scrape's outcome, written by the scraper, so a page can say why a
@@ -26,6 +27,7 @@ class PrometheusTarget(Base):
name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
url: Mapped[str] = mapped_column(String(500), nullable=False)
token: Mapped[str] = mapped_column(Text, nullable=False, default="")
token_from: Mapped[str | None] = mapped_column(String(50), nullable=True)
verify_tls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
interval_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=60)
keep: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
+12 -5
View File
@@ -12,13 +12,14 @@ from sqlalchemy import delete, func, select, update
from steward.auth.middleware import require_role
from steward.core.audit import log_audit
from steward.core.crypto import decrypt_secret
from steward.core.metric_series import latest_values, points
from steward.core.prometheus import TokenUnavailable
from steward.models.metrics import PluginMetric, PluginMetricLatest
from steward.models.users import UserRole
from . import library
from .models import PrometheusTarget, PrometheusWidgetTemplate
from .scheduler import target_token
from .scraper import ScrapeError, scrape
from .service import (OWN_METRICS, SOURCE, FormError, fold_states, form_values, is_moment, map_families,
parse_form, patterns, shown_value, target_state)
@@ -80,7 +81,8 @@ def _typed(form, target: PrometheusTarget | None = None) -> dict:
return {"name": form.get("name", ""), "url": form.get("url", ""),
"interval_seconds": form.get("interval_seconds", ""), "series_cap": form.get("series_cap", ""),
"keep": form.get("keep", ""), "drop": form.get("drop", ""),
"verify_tls": bool(form.get("verify_tls")), "has_token": bool(target and target.token)}
"verify_tls": bool(form.get("verify_tls")), "has_token": bool(target and target.token),
"token_from": target.token_from if target else None}
async def _name_taken(db, name: str, other_than: str | None = None) -> bool:
@@ -235,10 +237,15 @@ async def test_target():
existing = await db.get(PrometheusTarget, form["target_id"])
url = (form.get("url") or "").strip()
token = (form.get("token") or "").strip()
if not token and existing is not None and existing.token and not form.get("clear_token"):
token = decrypt_secret(existing.token, context=f"prometheus target {existing.name} token")
found: dict = {}
if not url.startswith(("http://", "https://")):
if not token and existing is not None and not form.get("clear_token"):
try:
token = await target_token(current_app, existing, url)
except TokenUnavailable as exc:
found["error"] = str(exc)
if found:
pass
elif not url.startswith(("http://", "https://")):
found["error"] = "Enter the address first: http://… or https://…"
else:
try:
+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
+6
View File
@@ -23,6 +23,10 @@ class TokenRefused(ScrapeError):
Home Assistant, for one, bans an address after a few refusals."""
class NoMetricsHere(ScrapeError):
"""The address answered 404: nothing is serving metrics there."""
async def scrape(url: str, token: str = "", verify_tls: bool = True,
transport: httpx.AsyncBaseTransport | None = None) -> dict[str, prometheus.Family]:
"""The endpoint's families. `transport` stands in for the network in tests."""
@@ -36,6 +40,8 @@ async def scrape(url: str, token: str = "", verify_tls: bool = True,
if response.status_code in (401, 403):
raise TokenRefused(f"Answered {response.status_code}: the token was refused. "
"Check the token, or make a new one on the service.")
if response.status_code == 404:
raise NoMetricsHere(f"Answered 404 {response.reason_phrase}, not metrics.")
if response.status_code != 200:
raise ScrapeError(f"Answered {response.status_code} {response.reason_phrase}, not metrics.")
body = bytearray()
+2 -1
View File
@@ -212,7 +212,7 @@ def form_values(target: PrometheusTarget | None) -> dict:
return {"name": target.name, "url": target.url, "interval_seconds": target.interval_seconds,
"series_cap": target.series_cap, "verify_tls": target.verify_tls,
"keep": "\n".join(target.keep or []),
"drop": "\n".join(target.drop or []), "has_token": bool(target.token)}
"drop": "\n".join(target.drop or []), "has_token": bool(target.token), "token_from": target.token_from}
def _bounded(form, key: str, default: int, low: int, high: int, says: str) -> int:
@@ -250,6 +250,7 @@ def parse_form(form, existing: PrometheusTarget | None = None) -> dict:
else:
token = ""
return {"name": name, "url": url, "token": token,
"token_from": None if typed or existing is None else existing.token_from,
"verify_tls": bool(form.get("verify_tls")), "interval_seconds": interval,
"keep": patterns(form.get("keep") or ""), "drop": patterns(form.get("drop") or ""),
"series_cap": cap}
@@ -37,8 +37,12 @@
<div class="field">
<label class="field-label" for="prom-token">Bearer token</label>
<input class="input" id="prom-token" name="token" type="password" autocomplete="new-password"
placeholder="{{ 'kept; type to change' if v.has_token else 'none' }}">
placeholder="{{ 'from a sign-in; type to change' if v.token_from else ('kept; type to change' if v.has_token else 'none') }}">
{% if v.token_from %}
<span class="field-hint">Comes from the sign-in on the {{ v.token_from }} plugin's page, so that one user covers this too. Typing a token here uses that instead.</span>
{% else %}
<span class="field-hint">Sent as "Authorization: Bearer …". Blank {{ "keeps the saved one" if v.has_token else "sends none" }}.</span>
{% endif %}
{% if v.has_token %}
<label style="display:flex;gap:0.5rem;align-items:center;margin-top:0.3rem;cursor:pointer;">
<input type="checkbox" name="clear_token" value="on"> <span class="field-hint">Remove the saved token</span>